medikoo / memoizee

Complete memoize/cache solution for JavaScript
ISC License
1.73k stars 61 forks source link

Allow `maxAgeSinceAccess` in addition to `maxAge` #126

Open blackarctic opened 2 years ago

blackarctic commented 2 years ago

This would function similarly to maxAge but the cache would expire x milliseconds from last access as opposed to x milliseconds from the first access.

For example:

memoized = memoize(fn, { maxAgeSinceAccess: 1000 }); // 1 second

memoized("foo", 3);
memoized("foo", 3); // Cache hit

setTimeout(function() {
    memoized("foo", 3); // Cache hit
}, 500);

setTimeout(function() {
    memoized("foo", 3); // Cache hit since it was accessed at 500ms, thus resetting the "timer".
}, 1000);

setTimeout(function() {
    memoized("foo", 3); // No longer in cache, re-executed
    memoized("foo", 3); // Cache hit
}, 2000);

This makes much more sense to me than maxAge, since we likely don't want to expire a memoized value that is actively being used. preFetch also doesn't make much sense as we likely have no need to recompute the value. We simply want to remove inactive items from the cache.

blackarctic commented 2 years ago

Happy to make a PR for this, but want to ensure interest first.

Rush commented 2 years ago

From a user's perspective of memoizee it makes sense. Would be useful for some use-cases like memoizing connections and other resources that don't technically change but are expensive to maintain.

medikoo commented 2 years ago

@blackarctic great thanks for this proposal. Still, wouldn't combining maxAge with max address this use case reliably?

Having that setup only max of last accessed items will be kept but for no longer than maxAge.

medikoo commented 2 years ago

Also @blackarctic can you outline what is the specific use case?

blackarctic commented 2 years ago

I don’t believe the existing combination accomplishes this goal. Combing maxAge and max says “I want to keep a max of x items in the cache for no longer than y milliseconds after first access”. Using just maxAgeSinceAccess says “I want to keep any number of items in the cache as long as they have been accessed in the last y milliseconds”. Combining maxAgeSinceAccess and max would give us “I want to keep a max of x items in the cache for no longer than y milliseconds after last access”.

As mentioned in the description, this use case of keeping items in the cache since last access (as opposed to first access) makes much more sense to me than maxAge, since we likely don't want to expire a memoized value that is actively being used. preFetch also doesn't make much sense as we likely have no need to recompute the value. We simply want to remove inactive items from the cache and keep active items in the cache.

medikoo commented 2 years ago

this use case of keeping items in the cache since last access (as opposed to first access)

This explains what you want, but does not describe the use case you have (what are you caching? Why last access dictates the freshness of a cached value, and not the retrieval timestamp ?)

Note it's a very first request like that (in near 9 years of this package being around). Either we're addressing a very rare use case, or this use case can be addressed in a better way.

blackarctic commented 2 years ago

Sure. Let’s say you want to memoize the conversion from any number of objects from shape A to shape B. You want to keep the object references the same so preFetch isn’t going to work because that will create new object references. You also don’t know how many you will have so you can’t use max. You can use maxAge but after x amount of time, the object references will be dropped and you will get bugs because you need the object references to persist. What we actually want is to persist the object references as long as they are actively being accessed.

I apologize, it’s hard for me to come up with specific examples because I feel almost every memoization example that uses maxAge is better served with maxAgeSinceAccess. Simple reason is don’t remove cached items that are actively being used.

Rush commented 2 years ago

I often memoize observables which keep subscribed to some resource, such as redis events or to a websocket. Such resource never changes so the goal of memoization is to just keep it active for as long as it's needed.

maxAge is best for cases when things generally change over time such as:

But if you're memoizing:

medikoo commented 2 years ago

What we actually want is to persist the object references as long as they are actively being accessed.

@blackarctic Why in this specific case, it's ok to assume that if object wasn't accessed for at least X period, it's safe to remove from cache, and it's not safe to remove if it's the least recently accessed object from all cached objects when count of them reached Y count?

To me it looks a great max (LRU) use case, still, your description feels purely theoretical, Can you describe a real-world case you're dealing with?

I often memoize observables which keep subscribed to some resource, such as redis events or to a websocket. Such resource never changes so the goal of memoization is to just keep it active for as long as it's needed.

Thanks @Rush for your input. Wouldn't relying on weak option work best here? In that case, it'll be memoized as long as it's used by the consumers.

Otherwise is this a real need you have currently, or more a nice to have thing, that you assume you may benefit in some cases?

blackarctic commented 2 years ago

@medikoo Sorry for the late reply. Looking back into this.

This situation I had in mind is a frontend render cycle, where max is not helpful because we can't limit by an arbitrary number since we can't know this arbitrary number beforehand. Essentially, in the frontend render cycle, we can actually safely assume that if our maxAgeSinceAccess is longer than it takes our render cycle to complete (including promises to resolve) then we can safely remove those references and all in the render cycle will have the same object. In this case, a maxAgeSinceAccess of a minute or two should be more than enough. With maxAgeSinceAccess pushing back the expiration on each access, it is impossible to have the memoized function return multiple references during the render cycle.

Render Cycle (using maxAgeSinceAccess)
  -> const a = getMemoizedValue() -  3ms --> Pushes back the expiration
  -> someOtherFunction() - 4ms
  -> const b = getMemoizedValue() - 3ms --> getMemoizedValue()'s cache has not expired. a is **equal** to b

Render Cycle (using maxAge)
  -> const a = getMemoizedValue() -  3ms --> Does not push back the expiration
  -> someOtherFunction() - 4ms
  -> const b = getMemoizedValue() - 3ms --> getMemoizedValue()'s cache has expired. a is **not equal** to b
medikoo commented 2 years ago

@blackarctic thanks for the explanations, still it still looks blurry to me.

Can you explain the use case without referring to the current memoizee API and without focusing on an eventual solution?

What exactly is the "Render Cycle" ? What is the "arbitrary number" you refer to?

(Also in memoizee maxAge as far as I remember is counted since function result resolved and not since the function was invoked)