AnyWhichWay
Sharing Promises

Home > How to memoize Promises so they can be shared to reduce redundant server and other calls, November 13th, 2016.

Introduction

Memoizing is the process of saving the results of expensive function calls so that they can be re-used to improve performance. This is usually applied to local computations. A typical example is the memoization of Fibonnaci computation. However, there are other types of calls that are not computationally expensive from a local CPU perspective, but are costly in time or may put a high load on a remote server. Database calls are a prime example.

Many database libraries use Promises to support the asynchronous retrieval of data, or callbacks that can be easily mapped into Promises. In this tutorial we show how Promise yielding functions can be memozied in order to share the Promise until it is resolved. This technique can be used to dramatically decrease disk I/O for database or file servers as an alternative to or in addition to traditional caching mechanisms.

Memoizing Promises

First, create a function that can wrap functions that yield Promises you would like to share and create a place to store the "memo", i.e. the signature of the arguments used to call the function. Although we could build a dynamic function, a Proxy is used because it is minimally invasive. A Map is used to store the memo because it can have anything as a key or a value and we will be using the function call arguments as keys. Note, the memo Map is added to the Proxy so as not to pollute the function.
function sharePromise(asyncFunction) {
	let proxy = new Proxy(asyncFunction,{
		apply: (target, thisArg, argumentsList) => {
			...
		}
	});
	proxy.memo = new Map();
	return proxy;
}
Next, add code that loops through the argumentList add adds nested Maps for any arguments not yet used in a call to the wrapped function. This creates a tree struture in which the leaf nodes are the Promises to be shared.
function sharePromise(asyncFunction) {
	let proxy = new Proxy(asyncFunction,{
		apply: (target, thisArg, argumentsList) => {
			let node = proxy.memo,
				promised = true, // assume the Promise has already been made
				promise;
			argumentsList.forEach((arg,i) => {
				let next = node.get(arg);
				if(!next) { // if the arg had not been seen before
					let value;
					if(i<argumentsList.length-1) { // if not the last argument
						value = new Map(); // create a Map for storing the next argument
					} else {
						let last = node; // create a variable to keep the previous node in a closure
						value = promise = target.call(thisArg,...argumentsList); // create the Promise by calling the original function
						promise.then(() => {
							last.delete(arg); // delete the reference to the arg after the Promise resolves
						});
						promised = false; // note that the Promise did not already exist
					} 
					node.set(arg,value); // store the value (a Map or a Promise if the leaf node)
					node = value; // jump to the next branch/node, will be ignored if this was the last arg
				} else { // the arg has been seen before
					node = promise = next; // jump to the next node, promise keeps getting set until the last loop
				}
			});
			if(promised) {
				console.log("Sharing promise ... ",thisArg,JSON.stringify(argumentsList)); // just here for the tutorial
			}
			return promise; // return the promise (this will either be a new Promise or the first one created for the aergument signature)
		}
	});
	proxy.memo = new Map();
	return proxy;
}

Testing The Code

The code below will memoize the provided function which will then share promises across calls.
async function f1(number,object) {
	return new Promise((resolve,reject) => {
		setTimeout(() => { resolve(number*object.age); },1000)
	});
}

f1 = sharePromise(f1);

let o1 = {age:10},
	o2 = {age:20};
f1(1,o1).then((result) => console.log("a: ",result));
f1(1,o2).then((result) => console.log("b: ",result));
f1(2,o1).then((result) => console.log("c: ",result));
f1(1,o1).then((result) => console.log("d: ",result));
f1(2,o1).then((result) => console.log("e: ",result));
The results are shown below:
Sharing promise ...  undefined [1,{"age":10}]
Sharing promise ...  undefined [2,{"age":10}]
a:  10
d:  10
b:  20
c:  20
e:  20

Additional Info

For more insight into the work that lead to this article see this article on highspeed indexing of data and the iMemoized library. Although the specific pre used in this tutorial is not used, a similar approach is implemented to lock data elements and share remote server calls in ReasonDB.

Copyright 2016, AnyWhichWay