Malcolm Crum
Malcolm Crum

Reputation: 4869

Aggregate multiple calls then separate result with Promise

Currently I have many concurrent identical calls to my backend, differing only on an ID field:

getData(1).then(...) // Each from a React component in a UI framework, so difficult to aggregate here
getData(2).then(...)
getData(3).then(...)

// creates n HTTP requests... inefficient
function getData(id: number): Promise<Data> {
  return backend.getData(id);
}

This is wasteful as I make more calls. I'd like to keep my getData() calls, but then aggregate them into a single getDatas() call to my backend, then return all the results to the callers. I have more control over my backend than the UI framework, so I can easily add a getDatas() call on it. The question is how to "mux" the JS calls into one backend call, the "demux" the result into the caller's promises.

const cache = Map<number, Promise<Data>>()
let requestedIds = []
let timeout = null;

// creates just 1 http request (per 100ms)... efficient!
function getData(id: number): Promise<Data> {
  if (cache.has(id)) {
    return cache;
  }
  
  requestedIds.push(id)
  if (timeout == null) {
    timeout = setTimeout(() => {
      backend.getDatas(requestedIds).then((datas: Data[]) => {
        // TODO: somehow populate many different promises in cache??? but how?
        requestedIds = []
        timeout = null
      }
    }, 100)
  }
  return ???
}

In Java I would create a Map<int, CompletableFuture> and upon finishing my backend request, I would look up the CompletableFuture and call complete(data) on it. But I think in JS Promises can't be created without an explicit result being passed in.

Can I do this in JS with Promises?

Upvotes: 2

Views: 164

Answers (3)

Malcolm Crum
Malcolm Crum

Reputation: 4869

I think I've found a solution:

interface PromiseContainer {
    resolve;
    reject;
}

const requests: Map<number, PromiseContainer<Data>> = new Map();
let timeout: number | null = null;
function getData(id: number) {
  const promise = new Promise<Data>((resolve, reject) => requests.set(id, { resolve, reject }))
  if (timeout == null) {
    timeout = setTimeout(() => {
      backend.getDatas([...requests.keys()]).then(datas => {
        for (let [id, data] of Object.entries(datas)) {
          requests.get(Number(id)).resolve(data)
          requests.delete(Number(id))
        }
      }).catch(e => {
        Object.values(requests).map(promise => promise.reject(e))
      })
      timeout = null
    }, 100)
  }
  return promise;
}

The key was figuring out I could extract the (resolve, reject) from a promise, store them, then retrieve and call them later.

Upvotes: 0

danh
danh

Reputation: 62686

The cache can be a regular object mapping ids to promise resolution functions and the promise to which they belong.

// cache maps ids to { resolve, reject, promise, requested }
// resolve and reject belong to the promise, requested is a bool for bookkeeping
const cache = {};

You might need to fire only once, but here I suggest setInterval to regularly check the cache for unresolved requests:

// keep the return value, and stop polling with clearInterval()
// if you really only need one batch, change setInterval to setTimeout
function startGetBatch() {
  return setInterval(getBatch, 100);
}

The business logic calls only getData() which just hands out (and caches) promises, like this:

function getData(id) {
  if (cache[id]) return cache[id].promise;
  cache[id] = {};
  const promise = new Promise((resolve, reject) => {
    Object.assign(cache[id], { resolve, reject });
  });
  cache[id].promise = promise;
  cache[id].requested = false;
  return cache[id].promise;
}

By saving the promise along with the resolver and rejecter, we're also implementing the cache, since the resolved promise will provide the thing it resolved to via its then() method.

getBatch() asks the server in a batch for the not-yet-requested getData() ids, and invokes the corresponding resolve/reject functions:

function getBatch() {
  // for any
  const ids = [];
  Object.keys(cache).forEach(id => {
    if (!cache[id].requested) {
      cache[id].requested = true;
      ids.push(id);
    }
  });
  return backend.getDatas(ids).then(datas => {
    Object.keys(datas).forEach(id => {
      cache[id].resolve(datas[id]);
    })
  }).catch(error => {
    Object.keys(datas).forEach(id => {
      cache[id].reject(error);
      delete cache[id]; // so we can retry
    })
  })
}

The caller side looks like this:

// start polling 
const interval = startGetBatch();

// in the business logic
getData(5).then(result => console.log('the result of 5 is:', result));
getData(6).then(result => console.log('the result of 6 is:', result));

// sometime later...
getData(5).then(result => {
  // if the promise for an id has resolved, then-ing it still works, resolving again to the -- now cached -- result
  console.log('the result of 5 is:', result)
});

// later, whenever we're done 
// (no need for this if you change setInterval to setTimeout)
clearInterval(interval);

Upvotes: 1

Evan Scallan
Evan Scallan

Reputation: 158

A little unclear on what your end goal looks like. I imagine you could loop through your calls as needed; Perhaps something like:

for (let x in cache){
   if (x.has(id))
      return x;
   } 

//OR

for (let x=0; x<id.length;x++){
  getData(id[x])
}



Might work. You may be able to add a timing method into the mix if needed.

Not sure what your backend consists of, but I do know GraphQL is a good system for making multiple calls.

It may be ultimately better to handle them all in one request, rather than multiple calls.

Upvotes: 1

Related Questions