Skip to content

Conversation

timacdonald
Copy link
Member

@timacdonald timacdonald commented Apr 6, 2025

We put things in the cache because the cache is fast; that doesn't mean hitting the cache is free.

This PR introduces a memoized cache driver. This new driver is a decorator around other cache drivers. It will remember values resolved from the cache and store them in memory for the remainder of the execution.

Cache::get('foo');         // hits the cache
Cache::get('foo');         // hits the cache
Cache::get('foo');         // hits the cache

Cache::memo()->get('foo'); // hits the cache
Cache::memo()->get('foo'); // does not hit the cache
Cache::memo()->get('foo'); // does not hit the cache

The following illustrates the values returned:

Cache::put('name', 'Taylor');
Cache::get('name');           // "Taylor"

Cache::put('name', 'Tim');
Cache::get('name');           // "Tim"

Cache::put('name', 'Taylor');
Cache::memo()->get('name');   // "Taylor"

Cache::put('name', 'Tim');
Cache::memo()->get('name');   // "Taylor"

The memo function accepts a driver name. This indicates the real driver that the memo driver should decorate:

// default driver...
Cache::memo()->get('name');

// redis driver...
Cache::memo('redis')->get('name');

// database driver...
Cache::memo('database')->get('name');

Each driver will get a unique memo decorator. This means values are not shared between different drivers:

Cache::driver('redis')->put('name', 'Taylor in Redis');
Cache::driver('database')->put('name', 'Taylor in the database');

Cache::memo('redis')->get('name');    // "Taylor in Redis"
Cache::memo('database')->get('name'); // "Taylor in the database"

When you call a method on the memo driver that is intended to mutate the value in the cache, e.g., put, remember, etc., we forget the memoized value and then call the same method on the decorated driver:

// assuming the default driver is 'redis'...

Cache::memo()->put('name', 'Taylor'); // writes to Redis
Cache::memo()->get('name');           // hits Redis
Cache::memo()->get('name');           // does not hit Redis
Cache::memo()->get('name');           // does not hit Redis

Cache::memo()->put('name', 'Taylor'); // forgets the memoized value for 'name' and writes to Redis
Cache::memo()->get('name');           // hits Redis
Cache::memo()->get('name');           // does not hit Redis
Cache::memo()->get('name');           // does not hit Redis

Note

I had hopes and dreams that if you wrote to the cache via the memo driver we could actively remember the value. That way you don't need to retrieve it after a write. Unfortunately there were too many inconsistencies between drivers and different mutation methods for that to work. For example, when you call increment the value returned from the cache is an int. If you then retrieve that value via get it will be a string of the int.

The memo driver does not fire events. Events will be fired by the underlying driver if it is called.

When calling methods that mutate the cached value, the memo driver will always forget memoized value even if the call to the underlying driver fails.

One last bonus benefit of the memo driver is that is ensures consistent values from the cache throughout a request. Imagine you attempt to get the same key in two different places within a request and you get two different results because a different process updated the value or perhaps it just TTLd out of the cache. That could cause unexpected runtime side effects on your application. Using Cache::memo ensures that you get the same result for the entire request or job lifecycle regardless of what happens in the cache while time progresses throughout the execution.

This idea has been bubbling away for a while and once we saw what was happening in some of our customers’ apps via Laravel Nightwatch, I knew we needed to make it happen.

@laravel laravel deleted a comment from github-actions bot Apr 6, 2025
Comment on lines +84 to +88
if (! $this->app->bound($bindingKey = "cache.__memoized:{$driver}")) {
$this->app->scoped($bindingKey, fn () => $this->repository(
new MemoizedStore($driver, $this->store($driver)), ['events' => false]
));
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We bind the memo drivers to the container as scoped instances so they are refreshed in Octane and the queue worker.

@timacdonald timacdonald marked this pull request as ready for review April 9, 2025 07:23
@tpetry
Copy link
Contributor

tpetry commented Apr 9, 2025

Isn't this a more specific (limited?) implementation of a two-level cache? Like two drivers used one after nother? So an array driver first with something else last? A more generic multi-level implementation would work in the same. And even have more capabilities like: array > redis > storage to have multiple cache-levels with decreasing speed but much more storage.

So Cache::mycache()->get() would just an abstract store that calls its child stores one after nother for get() oeprations, while e.g. set() would be called on all of them.

@devajmeireles
Copy link
Contributor

Isn't this a more specific (limited?) implementation of a two-level cache? Like two drivers used one after nother? So an array driver first with something else last? A more generic multi-level implementation would work in the same. And even have more capabilities like: array > redis > storage to have multiple cache-levels with decreasing speed but much more storage.

So Cache::mycache()->get() would just an abstract store that calls its child stores one after nother for get() oeprations, while e.g. set() would be called on all of them.

I think you're right. The difference is that he simplified it.

@devajmeireles
Copy link
Contributor

@timacdonald,

That's really cool. Is there a possibility of adding a refresh method? IMHO, that's the only missing part in this PR 😄

Cache::put('name', 'Taylor');
Cache::get('name');           // "Taylor"

Cache::put('name', 'Tim');
Cache::get('name');           // "Tim"

Cache::put('name', 'Taylor');
Cache::memo()->get('name');   // "Taylor"

Cache::put('name', 'Tim');
Cache::memo()->get('name');   // "Taylor"

Cache::memo()->refresh();

Cache::memo()->get('name');   // "Tim"

@devajmeireles
Copy link
Contributor

Any thoughts related to tests? 🤔

@timacdonald
Copy link
Member Author

timacdonald commented Apr 10, 2025

@tpetry, I can see a multi-level cache being a good addition to the framework.

That being said, I don't see this as the same thing. I would expect a multi-level cache to respect the TTL up and down the cache stack. If we had a cache stack implementation, e.g., Cache::driver('stack:array,redis,s3')->get(...);, I would expect the array driver to still respect the TTL and therefore the value could be TTLd within the current request and would have different return values within the one execution.

I feel that Cache::memo would be useful alongside a multi-level cache, e.g., Cache::memo('stack:redis,s3')->get(...);.

I'm not entirely sure how common it is for Laravel applications to have multiple cache levels. Is this a pattern you've commonly used or seen in Laravel applications?

@timacdonald
Copy link
Member Author

@devajmeireles

Any thoughts related to tests? 🤔

The memo driver will be flushed between tests as each instance is bound to the container.

Is there a possibility of adding a refresh method? IMHO, that's the only missing part in this PR 😄

Can you share some real-world code where doing that is something you would want? I can see it is interesting but I can't see where I would use it off the top of my head.

@chu121su12
Copy link
Contributor

I am using user-land implementation of the 2-level cache in production now. Great feature indeed.

note

Straying from decorator, why not add a force<Mutate>() methods to, well, force the mutation to the deocrated driver so a simple == check can be added in the regular mutation methods?

@chu121su12
Copy link
Contributor

Otherwise, considering repository implementation consider null as not cached and the memo->get is using array_key_exists why not add the == check selectively only on selective methods (I'm thinking only put, putMany, and forever)? Forcing would then require call to memo->forget.

@devajmeireles
Copy link
Contributor

@devajmeireles

Any thoughts related to tests? 🤔

The memo driver will be flushed between tests as each instance is bound to the container.

Is there a possibility of adding a refresh method? IMHO, that's the only missing part in this PR 😄

Can you share some real-world code where doing that is something you would want? I can see it is interesting but I can't see where I would use it off the top of my head.

Considering the primary objective of this PR, refresh would be helpful to update the state saved in the memo within the same life cycle, because there are several cases where we can update values ​​saved in the cache to retrieve them later.

A real example that I can take from one of my software is an auditing log concept, which as actions occur in the life cycle, I store them and gradually update them so that only at the terminate of a Middleware do I persist them using Concurrency.

@mahmoudmohamedramadan
Copy link
Contributor

@tpetry, I can see a multi-level cache being a good addition to the framework.

That being said, I don't see this as the same thing. I would expect a multi-level cache to respect the TTL up and down the cache stack. If we had a cache stack implementation, e.g., Cache::driver('stack:array,redis,s3')->get(...);, I would expect the array driver to still respect the TTL and therefore the value could be TTLd within the current request and would have different return values within the one execution.

I feel that Cache::memo would be useful alongside a multi-level cache, e.g., Cache::memo('stack:redis,s3')->get(...);.

I'm not entirely sure how common it is for Laravel applications to have multiple cache levels. Is this a pattern you've commonly used or seen in Laravel applications?

I completely agree with @tpetry. This seems like an extra layer of caching on top of the existing one, which is uncommon and may lead developers to question why there's a cache for something Laravel already handles out of the box. It also introduces additional overhead—more logic and processing time just to check if a cached value exists. On top of that, it could encourage even more unnecessary layers in the future.

@taylorotwell taylorotwell merged commit f519ab8 into laravel:12.x Apr 11, 2025
58 checks passed
@Treggats
Copy link
Contributor

@timacdonald can I just say that I love how elaborate your PR descriptions are. 🔥

@timacdonald
Copy link
Member Author

Thank you, @Treggats

@decadence
Copy link
Contributor

Does this driver proxy all calls to the underlying driver? Including if I call the remember method (which I don't see in MemoizedStore class)?

@rodrigopedra
Copy link
Contributor

@decadence

The remember() method is part of the Illuminate\Cache\Repository, which builds up a Illuminate\Contracts\Cache\Store implementation, which the Illuminate\Cache\MemoizedStore is an implementation.

So at the end when calling Cache::memo()->remember(...) the calls the Repository makes to the Store will be delegated to the underlying Store, the MemoizedStore decorates upon.

@decadence
Copy link
Contributor

@rodrigopedra Thanks!

@joostdebruijn
Copy link
Contributor

joostdebruijn commented Apr 17, 2025

Thanks @timacdonald! Nice feature. I was wondering why the MemoizedStore doesn't extend the TaggableStore? I'm using tagged Redis caches frequently in one of my applications and I was wrapping some calls to the cache in a call to the array cache driver to achieve something similar to this. However, as the MemoizedStore doesn't support tags I cannot replace that pattern unfortunately.

For the sake of testing I just patched the MemoizedStore to extend the TaggableStore and that seems to work on the surface, but I'm not sure if it is that easy. 😉

@inmanturbo
Copy link
Contributor

oOh wow @timacdonald. This is very nice!

@royduin
Copy link
Contributor

royduin commented Apr 25, 2025

If anyone needs it; a multi level cache implementation: https://github.com/rapidez/laravel-multi-cache

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.