Homemade caching with Laravel — part two

--

If you are landing here without having read the first part of the story — please go and read it!

In this second part, we will proceed and complete the technical showcase of the strategy, code snippets included. Also, we will have a look at a number of clever things and optimization in our strategy:

  • warming up a cache
  • write-thru refreshes
  • the right timing

Step 5 — Clear Cache for User

Let us pick up the conversation back again, right where we left.

How to clear your cache? With a rubber, of course! Image source: kinsta.

We have a clearAllForUser() which enables clearing the cache for a specific user (we'll talk about the indexing later); since a database cache store doesn't support tags in Laravel, we implemented a safe-prefix scheme ourselves:

protected static function clearAllForUser(string $userId = null): void
{
$userIdOrDefault = $userId ?? self::getUserId();
$prefix = self::getCacheKeyPrefixForUser($userIdOrDefault);
$allUserKeys = self::getAllKeysContaining($prefix);
$missingKeys = 0;
$count = count($allUserKeys);
foreach ($allUserKeys as $userKey) {
//left-trim, because the prefix is appended by the database store calls...
$userKeyWithoutPrefix = ltrim($userKey, config(self::$cachePrefixConfigKey));
//in case we get tricked by prefixes and stuff...
self::getCache()->has($userKeyWithoutPrefix) ? $missingKeys : $missingKeys++;
//forget always returns true, regardless - not very useful imho
self::getCache()->forget($userKeyWithoutPrefix);
}
if ($missingKeys == 0) {
Log::info("$count responses cache entries flushed for user $userIdOrDefault");
} else {
Log::warning("Expected $count responses cache entries to be flushed for user $userIdOrDefault, but $missingKeys were missing - review your configuration");
}
}
protected static function getUserId(): string
{
return Auth::check() ? (string) Auth::id() : 'none';
}

private static function getCacheKeyPrefixForUser(string $userId): string
{
// Indexing by companyId is not only handy for prefix-based invalidation,
// but also to make the caching scope private to the company itself.
$companyId = self::getCompanyId($userId);
return "@company:" . $companyId . '@';
}

A smart strategy involves employing a "LIKE" query in this context. Refer to the getCacheKeyPrefixForUser() method and observe the use of '@' as a postfix for the prefix. This is done to prevent conflicts in cases where two companies share the same prefix sequence. For example, if there is a company with the ID 123, the prefix would be "@company:123" Similarly, for another company with the ID 12345, the prefix would be "@company:12345". Using the @ postfix ensures that a like query won't mistakenly match both cases, mitigating potential conflicts.

private static function getAllKeysContaining(string $prefix): Collection
{
$config = config(self::$driverConfigKey);
$table = config(self::$storeNameConfigKey);
if ($config == 'database' && $table) {
$keys = DB::table($table)->where('key', 'LIKE', "%$prefix%")->pluck('key');
return $keys;
} else {
throw new InvalidArgumentException("This cache driver does not support fetching all keys");
}
}

Step 6 — Invalidating the Cache

We need an invalidate-on-write: whenever the state of the application changes for the main resource — or any other resource associated to it — the whole cache for the user needs to be invalidated. The invalidation needs to take place on state-amending calls, and not necessarily on all POSTs or so: actually, expensive POST calculations are the reason why we want caching in the first place, so we don’t want to invalidate the cache there! To ignore those requests, we rely on a simple tag in the name of the route itself.

Also say that the rebuild goes into the terminate part, because it might be slower (even if non-blocking) and we don’t want to delay the response in any way; in general, we make sure to pass onto the request in a finally() block - failing on a middleware applied to all requests is fatal.

namespace App\Http\Middleware;

use App\Traits\ManageWebCache;
use Closure;
use Illuminate\Http\Request;
use Throwable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;

class ClearCacheForUser
{
use ManageWebCache;

private bool $rebuildCache = false;

private function getRouteFullName(Request $request): string
{
$route = Route::getRoutes()->match($request);
$name = $route->getName();
$actionName = $route->getActionName();
return "actionName:$actionName-name:$name";
}

/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
try {
if (config('cache.cache_enabled')) {
if (
$request->isMethod(Request::METHOD_DELETE) ||
$request->isMethod(Request::METHOD_PATCH) ||
$request->isMethod(Request::METHOD_POST) ||
$request->isMethod(Request::METHOD_PUT) ||
$request->isMethod(Request::METHOD_PURGE)) {
if (!str_contains($this->getRouteFullName($request), 'nocacheclear')) {
$this->rebuildCache = true;
self::clearAllForUser();
}
}
}
} catch (Throwable $t) {
$message = $t->getMessage();
Log::warning("Could not clear cache for incoming user request: $message");
} finally {
return $next($request);
}
}

/**
* Handle an outgoing request.
*/
public function terminate($request, $_)
{
try {
if (config('cache.cache_enabled')) {
if ($this->rebuildCache) {
Log::info("Rebuilding the cache for outcoming user write response (e.g. POST)");
self::rebuildAllForUser();
}
}
} catch (Throwable $t) {
$message = $t->getMessage();
Log::warning("Could not rebuild the cache for outcoming user response: $message");
}
}
}

Above code file is Laravel middleware named ClearCacheForUser that is designed to handle caching-related tasks for incoming and outgoing HTTP requests. Here's a breakdown of what this middleware does:

  1. Middleware Usage. This middleware is responsible for clearing and rebuilding caches in response to certain HTTP request methods, such as DELETE, PATCH, POST, PUT, and PURGE.
  2. Route Caching Control. The middleware checks whether caching is enabled by examining the RESPONSE_CACHE_ENABLED environment variable. If caching is disabled, it bypasses cache-related operations.
  3. Incoming Request Handling (handle method). For incoming requests that are write operations (e.g., POST, PUT), the middleware checks if the route’s name contains ‘nocacheclear’. If not, it sets the $rebuildCache flag to true and calls the clearAllForUser() method to clear the cache for the user. This is done in a try-catch block to handle any exceptions that might occur.
  4. Outgoing Response Handling (terminate method). For outgoing responses that are write operations (e.g., POST), the middleware checks if the route’s name contains ‘nocacheclear’. If not, it logs an informational message and calls the rebuildAllForUser() method to rebuild the cache for the user. This operation is non-blocking, even if the response has already been sent.
  5. Error Handling. If an exception is thrown during cache clearing or rebuilding, the middleware logs a warning message with details about the error.
  6. Middleware Traits. This middleware utilizes the ManageWebCache trait, which is expected to contain methods for clearing and rebuilding the cache.

Overall, this middleware is designed to manage caching operations for specific HTTP request methods and is sensitive to route names containing nocacheclear to determine whether to clear or rebuild the cache. It's a valuable component for applications where cache management is a critical aspect of performance optimization.

Performance, that’s what you expect from your overclocked 16gHz custom quad core, as well as from your website. Image by Onur Binay on Unsplash.

Re-building

Previously, we explored the benefits of caching and how we can efficiently retrieve data from cache. However, it’s essential to acknowledge that cached data can become outdated over time, especially when end-users perform actions that alter the nature of the data, so here is this step we’ll dive into a crucial step that ensures the cache remains validated and up-to-date.

In the earlier section, we introduced the concept of protecting a route with the ClearCacheForUser middleware, which checks if the route name doesn't end with the nocacheclear string. If the route doesn't meet this criteria, it indicates that data may have been modified by an end-user, and it's time to rebuild the cache.

To accomplish this, we can implement a method called rebuildAllForUser() within our trait. This method allows us to specify a user ID, or by default, it will retrieve the currently logged-in user using Auth::user(). The primary purpose of this method is to trigger an Artisan command that runs in the background when a user accesses a route that does not contain the nocacheclear string.

This process ensures that the cache is promptly updated when necessary, maintaining data accuracy and consistency, even in dynamic environments where user actions can impact the information stored in the cache. Stay tuned as we delve deeper into the technical details of implementing cache validation and rebuilding.

protected static function rebuildAllForUser(string $userId = null): void
{
$userIdOrDefault = $userId ?? self::getUserId();
chdir(base_path());
$companyId = self::getCompanyId($userIdOrDefault);
$commandName = self::getWarmUpRoutesCommandName();
foreach (Model::whereCompanyId($companyId)->pluck('id') as $modelId) {
$command = "php artisan $commandName $modelId &"; //fork into the background
exec($command);
}
Log::info("Responses cache entries will be rebuilt for user $userIdOrDefault");
}

Here the getWarmUpRoutesCommandName() method, which will return a Laravel command file name (to find out more about Laravel custom commands see here).

protected static function getWarmUpRoutesCommandName()
{
return (new WarmUpRoutesCommand())->getName();
}

WarmUpRoutesCommand — re-build cache

Here we will define how to warm-up cache using a laravel custom command.

This command WarmUpRoutesCommand might accept an modelId for which it should re-build cache, Here's a breakdown of what this code is doing:

  1. Namespace and Imports. The code begins by declaring the namespace and importing the necessary classes and dependencies.
  2. Class Declaration. The WarmUpRoutesCommand class extends Laravel's Command class and uses the ManageWebCache trait.
  3. Properties and Constructor:
  • protected $signature and protected $description define the command's name and description for use in the command line.
  • protected $modelController is an instance variable for the ModelController.
  • The constructor initializes the modelController by instantiating the ModelController class.

And further, Helper Methods:

  • getTableDefaultContent(): A method that returns an array of default content.
  • getTableDefaultRequest(): A method that constructs a ModelDetailFormRequest using route information and content.
  • warmUpRoutes(): A method for pre-caching routes associated with a specific model. It logs the process and handles any exceptions.

Finally, the handle() Method. The handle() method is the entry point of the command. It determines whether to warm up routes for a single model or all models.

  • If a modelId argument is provided, it retrieves the model by its ID and calls warmUpRoutes() for that model.
  • If no modelId argument is provided, it retrieves all models, ordered by ID, and iterates through them, calling warmUpRoutes() for each model.
namespace App\Console\Commands;

use App\Http\Controllers\ModelController;
use App\Model;
use Illuminate\Console\Command;
use Illuminate\Http\Request;
use App\Http\Requests\ModelDetailFormRequest;
use App\Traits\ManageWebCache;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;

class WarmUpRoutesCommand extends Command
{
use ManageWebCache;

protected $signature = 'routes:warmup {modelId?}';

protected $description = 'Warm up selected routes for a given model, or all of the larger models if no modelId is provided';

/** @var ModelController */
protected $ModelController;

public function __construct()
{
parent::__construct();
$this->ModelController = app()->make(ModelController::class);
}

private function getTableDefaultContent(...$args): array
{
return [
// payload
];
}

private function getTableDefaultRequest(string $modelId, string $companyId): ModelDetailFormRequest
{
$uri = route('api_model_table_detail_cacheable_nocacheclear', [$companyId, $modelId], $refresh = false);
$arg = [
// prepare additional arguments here
]
$content = $this->getTableDefaultContent($arg);
return ModelDetailFormRequest::create($uri, 'POST', $content, [], [], [], json_encode($content));
}

private function warmUpRoutes(Model $model)
{
Log::info("Warming up routes for model {$model->id}...");
try {
$this->modelController->detailsTableCacheableRefresh($model->company, $model, $this->getTableDefaultRequest($model->id, $model->company->id));
// more controller methods here ...
} catch (\Throwable $t) {
$message = $t->getMessage();
Log::error("Routes warm-up failed for model {$model->id}: $message. This may impact the response times for the heaviest endpoints.");
}
}

public function handle()
{
// for single plan
if ($modelId = $this->argument('modelId')) {
$model = Model::find($modelId);
if ($model) {
$this->warmUpRoutes($model);
}
} else {
// for all plans
$models = Model::orderBy('id', 'DESC')->get();
$foreach($models as $model) {
$this->warmUpRoutes($model);
}
}
}
}

Warming up, in a scheduled fashion

An axiom of caching is: if you hit a cold cache, that’s a miss, and you will pay for producing the answer (well in our case, the response!). Therefore, it is a very good idea to warm-up the cache. And, you can do it at regular times relying on Laravel’s scheduling facilities. You can get a little smarter here too: you should just recreate the entry without dropping it first, because client requests may still come in between! Avoiding a full drop is very good for performance reasons: you try and minimize the chance of the incoming requests hitting a cold cache actually, which can be the case if the time to rebuild the cached entry (in our case, regenerating the response) is not negligible.

A focused warm-up is the secret for a great performance. Image by Nigel Msipa on Unsplash.

Dropping everything

Let’s delve into the topic of invalidation strategies. When a user makes changes to a resource, it’s important to consider that each resource can be linked to a primary entity within the domain, such as a Company in our case. The central concept in our entity or data model is what we refer to as the diamond model, which, in more abstract terms, can be seen as the pinnacle of a pyramid (or of the lattice for those with a mathematical inclination). It's crucial to understand that modifying a resource X doesn't necessarily mean that the entire graph stemming from the top-level Company model needs to be discarded.

To illustrate this, let’s consider an example: we might be caching both X and Y, and it's entirely possible for Y to remain valid even after X undergoes changes. For instance, if X represents an Employee and Y represents a Department, there's no need to rebuild the department's information if only the telephone number of an employee is modified. The extent to which you can optimize this process depends on your specific domain model and other factors - it's a matter of fine-tuning.

In more general terms, without making any assumptions, it’s advisable to invalidate at least the transitive closure of your model. This means dropping the cache for X and all the models that can be reached from it by traversing classic relational links like BelongsTo. Currently, Laravel does not provide a built-in feature to automatically collect all the dependent models of a given one, which would involve some recursion and reflection. However, it's important not to get lost in the complexities of this process. It's essential to carefully evaluate the trade-offs. In our case, simply clearing everything from the cache was a suitable approach to start with.

Cleaning — Where and how?

Rubber does not play well with silicon. Image by iStockphoto.

When it comes to managing cache in your Laravel applications, it’s crucial to consider cache invalidation, especially when data modifications occur through various channels like Laravel Nova, Tinker sessions, or direct database operations. Invalidating the cache solely through middleware might not suffice, and web users could potentially encounter outdated or stale data. To address this issue, Laravel Nova provides a helpful feature in the form of lifecycle hooks. These hooks allow you to clear and rebuild the cache every time data is modified.

One way to implement this cache management is through a Nova trait known as MustInvalidateCache. This trait leverages Laravel Nova's lifecycle methods to handle cache updates. For instance, after an update operation is performed, the afterUpdate() method triggers the cache to be rebuilt. This same logic is applied to other operations like deletion, force deletion, and creation, ensuring that the cache remains up to date with the latest data changes.

Additionally, you can achieve cache invalidation at the model level by using boot-style constructs, which are essentially event listeners within your models. However, it's essential to exercise caution when implementing these listeners within your models, as they directly affect the core of the ORM. Complex or resource-intensive operations in this context can have a significant impact on the application's performance.

When managing caches, it’s also advisable to avoid cluttering the observation space with model-based observers, especially when dealing with caching intricacies. Model-based observers are best suited for domain-level activities, and introducing them into the caching process can lead to confusion.

If you find yourself overwhelmed by cache management complexities, it might be an opportune moment to reassess your caching strategy. Cache should be a tool to enhance performance, not a source of headaches. Just like timeouts or asynchronous operations, cache serves to optimize performance and should not be used as a way to mask underlying performance issues. It’s essential to strike a balance between cache management and the overall performance of your Laravel application.

MustInvalidateCache Nova trait can look like this, which will be using our original implementation from ManageWebCache, basically perform clear cache on every action from admin side.

namespace App\Nova\Traits;

use App\Traits\ManageWebCache;
use Illuminate\Database\Eloquent\Model;
use Laravel\Nova\HasLifecycleMethods;
use Laravel\Nova\Http\Requests\NovaRequest;

trait MustInvalidateCache
{
use HasLifecycleMethods, ManageWebCache;

abstract protected static function rebuildCache(NovaRequest $_, Model $__): void;

public static function afterUpdate(NovaRequest $_, Model $__)
{
self::rebuildCache($_, $__);
}

public static function afterDelete(NovaRequest $_, Model $__)
{
self::afterUpdate($_, $__);
}

public static function afterForceDelete(NovaRequest $_, Model $__)
{
self::afterDelete($_, $__);
}

public static function afterCreate(NovaRequest $_, Model $__)
{
self::afterUpdate($_, $__);
}
}

Conclusion and remarks

Just like timeouts, caching feels a little like cheating, although in a good way: if things are really to slow, you are not solving the issue at the very core. In particular, you should make sure that rebuilding the whole cache for a user, so namely for all the cacheable resources they may request, takes a reasonable time. By looking at our example implementation: an incoming request which is modifying the state first clears and then rebuilds the whole cache. If rebuilding is not fast enough, the user may still see stale stuff upon the very next request. For example, they modify the Company name but the caching doesn't catch up fast enough, showing stale data on the frontend - very bad!

Use caching with care, and keep in mind that classic non-functional requirements, in particular the expected response times of a modern UX, do matter. Caching should never be a blanket solution, unless you are very comfortable because the complexity is low anyway (again, the example of read-only shopping catalogues etc).

Blog by Riccardo Vincelli and Usama Liaquat brought to you by the engineering team at Sharesquare.

--

--

Sharesquare.co engineering blog by R. Vincelli

This is the Sharesquare.co engineering blog, brought to you by Riccardo Vincelli, CTO at Sharesquare. Real-life engineering tales from the crypt!