Idea: Caching Meteor method results on client


#1

So I had an idea: I have a quite expensive method call which is called quite frequently due to (React) UI changes, but 99.99% of the time returns the same result for the same parameters.

So I hacked together my own little caching function (without any invalidation yet), which actually works beautifully. It uses localForage to store the method results on the clients.

Meteor.callCached = function() {
	const originalCb = typeof arguments[arguments.length-1] == 'function' ? arguments[arguments.length-1] : null;
	if(!originalCb)	{
		return Meteor.call.apply(null, arguments);
	}	
	const localKey = Array.from(arguments).map(arg => {
		if(typeof arg == 'function') return null;
		if(Array.isArray(arg)) return arg.map(val => String(val).length < 10 ? String(val) : String(val).substr(10,999999)).join('-');
		if(typeof arg == 'object') return Object.keys(arg).map(key => {let val = String(arg[key]); val.length < 10 ? val : val.substr(10,999999); return `${key}-${val}`}).join('-');
		return arg;
	}).filter(a => !!a).join('-');

	return localforage.getItem(localKey, (err, value) => {
		if(err) console.error(err);
		if(!err && value && originalCb) return originalCb(err, value);
		if(err || !value) {
			arguments[arguments.length-1] = (err, resp) => {
				if(err) console.error(err);
				else localforage.setItem(localKey, resp, err => {
					if(err) console.error(err);
				});
				return originalCb(err, resp);
			}
			return Meteor.call.apply(null, arguments);
		}
	});
}

Please let me know what you think, if this is for some reason a bad idea, any improvement suggestions … if someone is interested I can make a small package out of it and release it on github.

Cheers, Patrick


#2

It looks pretty nice - my one suggestion would be caching on the server, if many users frequently call this function then this caching will have a limited improvement when compared to caching on the server which will impact everyone. We use this pattern in our admin panel for some analytics that are quite heavy to compute - if two users hit at the same time (within 1 minute of each other) they share the same result, the next person to call after 1 minute gets the new result, and everyone who calls within the next minute shares that


#3

Hmm that makes sense to also cache it on the server for the most common parameters. Some of the parameters really depend from user to user, so this doesn’t always make sense.
Thanks for your input! :smile:

I was just wondering if Meteor isn’t doing something like this already out of the box and I was missing something?! It baffles me that there is no caching at all in place by default?! Am I missing something? Is this somehow bad practice for a reason I can’t see yet?


#4

It’s not bad practice - but its uncommon, calling your meteor methods too frequently could be considered poor reactivity in your UI - if you are happy to say “don’t ever rerun the same method call with the same arguments on a reactive data change” then you shouldn’t be making the call when the arguments don’t change.

Something like this will trigger a meteor call whenever any data passed into the template changes, when you only care about 3 parameters:

...
Tracker.autorun(() => {
const data = Template.currentData();
Meteor.call("myFunc", data.arg1, data.arg2, data.arg3, ...);
})

It is here you should really be caching the result, you could compare data.arg{1-3} against the arguments used when the method call was last ran, and if different you could re-run, otherwise skip. A nice way to do this is Tracker.guard it allows you to depend on a subset of a reactive data source:

...
Tracker.autorun(() => {
const data = Tracker.guard(() => {
  const data = Template.currentData();
  return {arg1: data.arg1, arg2: data.arg2, arg3: data.arg3};
});
Meteor.call("myFunc", data.arg1, data.arg2, data.arg3, ...);
})

it’s not built into tracker but here is the snippet:


#5

Thanks a bunch for your insights.
The thing is: in my case the parameters do change during a typical user session. but if the parameters are the same, 99.9% of the time the call will return the same result.
In my case (React) I would compare my prevProps with this.props - but this does not help in this case, as the parameters definitely changed but still, I have to somehow cache the results if the same params already occured some time ago.


#6

I’m not sure I follow - though I’m not really familiar with react, but if the parameters don’t change you should be able to not call the method, can you show your UI code that triggers the method?


#7

I think I can explain:
My app is called Orderlion - with this app a restaurant owner can do his procurement over a single platform. Meaning, the restaurant f&b manager can order all his good from all his suppliers over Orderlion.

If the user chooses a supplier, in the sidebar, the (sometimes huge!) item categories tree is loaded via a Meteor.call() and displayed. So the this.props of the tree basically are the supplierId and some other stuff. As long as the user stays within this one supplier the tree in the sidebar isn’t even re-rendered, so all is fine.

now the user navigates to another supplier --> this.props (or supplierId for that matter) changed --> new method call --> new item categories tree.

If the user now navigates back to the first supplier the supplierId changed again, so only comparing the new supplierId to the old supplierId isn’t enough - I need to cache the exact item categories tree for the first supplierId and just return it from cache.

To keep all this as general as possible I decided to code my own Meteor.callCached function.


#8

Ah, I understand now - you probably do have the right idea then, I don’t think you need localForage for this though, unless you care about caching across browser reloads? Also, how do you invalidate this cache (e.g., when will you call it again?)


#9

Yes I want to cache also if there is some browser reload somewhere. :smile:

Invalidation: Not perfect I know, since this is now hardcoded for every call but better than nothing :slight_smile:

Meteor.callCached = function() {
	const originalCb = typeof arguments[arguments.length-1] == 'function' ? arguments[arguments.length-1] : null;
	if(!originalCb)	{
		return Meteor.call.apply(null, arguments);
	}	
	const localKey = Array.from(arguments).map(arg => {
		if(typeof arg == 'function') return null;
		if(Array.isArray(arg)) return arg.map(val => String(val).length < 10 ? String(val) : String(val).substr(10,999999)).join('-');
		if(typeof arg == 'object') return Object.keys(arg).map(key => {let val = String(arg[key]); val.length < 10 ? val : val.substr(10,999999); return `${key}-${val}`}).join('-');
		return arg;
	}).filter(a => !!a).join('-');

	return localforage.getItem(localKey, (err, value) => {
		if(err) console.error(err);
		if(!err && value && originalCb && value.data && value.ts && moment().diff(value.ts, 'minutes') < 60) {
			return originalCb(err, value.data);
		} else {
			arguments[arguments.length-1] = (err, resp) => {
				if(err) console.error(err);
				else localforage.setItem(localKey, { ts: new Date(), data: resp }, err => {
					if(err) console.error(err);
				});
				return originalCb(err, resp);
			}
			return Meteor.call.apply(null, arguments);
		}
	});
}

#10

looks like that will do the trick!


#11

I also use something similar to this but I actually do the caching on the server. I use a modified version of validated method that allows to pass a property called cacheTTL to each method and uses memory-cache package for caching the method response.

Has been working correctly for some time now.


#12

That sounds awesome, is it a package?


#13

Not yet, but im thinking about it. It fixes an issue with npm simple-schema validation and adds this cache feature. Also thinking about adding a redis option so that the cache can be shared between multiple servers.

Ill post it on on the forum when I publish it, soon.


#14

This really sounds to me that you should be using a database subscription! Instead of calling a Method, you could simply subscribe to “supplierItems” passing the supplier ID. When the user navigates to another supplier, just do not stop the first subscription so all data will be kept on the minimongo… You could even leverage this with a “favorites” subscription, that could be grounded on the client so it would not even need to be loaded from the server (maybe without the price field, which would need re-actualisation) so that would load super fast, and even offline for that matter.

Although I see a great opportunity for your method caching mechanic, I do feel that your exact problem has a way easier solution already provided by Meteor.


#15

Yeah I agree with @Salketer, this is much easier with Minimongo and subscriptions

I use meteorhacks:subs-manager when I want to temporarily hold onto data so it’s there instantly when the user goes back