Skip to content

Commit 3dc61bd

Browse files
committed
feat: Implement dynamic tagging for API cache with enhanced cache invalidation logic
1 parent f0ddac4 commit 3dc61bd

File tree

5 files changed

+365
-76
lines changed

5 files changed

+365
-76
lines changed

.github/workflows/tests.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,13 @@ jobs:
4747
4848
- name: Install dependencies
4949
run: |
50-
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
51-
composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction
50+
COMPOSER_MEMORY_LIMIT=-1 composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --with="laravel/framework:${{ matrix.laravel }}" --with="orchestra/testbench:${{ matrix.testbench }}"
5251
5352
- name: List Installed Dependencies
5453
run: composer show -D
5554

5655
- name: Execute tests
57-
run: vendor/bin/phpunit
56+
run: php -d memory_limit=512M vendor/bin/phpunit
5857

5958
- name: Check code style
6059
run: vendor/bin/phpcs -p --standard=PSR2 --runtime-set ignore_errors_on_exit 1 --runtime-set ignore_warnings_on_exit 1 src tests

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,4 @@
4949
]
5050
}
5151
}
52-
}
52+
}

config/laravel-page-speed.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@
131131
'application/vnd.api+json',
132132
],
133133
'purge_methods' => ['POST', 'PUT', 'PATCH', 'DELETE'], // HTTP verbs that invalidate cached GETs
134+
'dynamic_tagging' => [
135+
'enabled' => env('API_CACHE_DYNAMIC_TAGS', true),
136+
'ignore_segments' => ['api'],
137+
'normalize_ids' => true,
138+
'max_depth' => 5,
139+
],
134140
],
135141

136142
/*

src/Middleware/ApiResponseCache.php

Lines changed: 190 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Closure;
66
use Illuminate\Support\Facades\Cache;
77
use Illuminate\Support\Facades\Log;
8+
use Illuminate\Support\Str;
89

910
/**
1011
* API Response Cache Middleware
@@ -267,12 +268,17 @@ protected function indexCacheKey($store, $cacheKey, array $tags, $ttl)
267268
}
268269

269270
try {
270-
$indexKey = $this->getTagIndexKey($tags);
271-
$keys = $store->get($indexKey, []);
272-
$keys[$cacheKey] = now()->addSeconds($ttl)->getTimestamp();
271+
$expiry = now()->addSeconds($ttl)->getTimestamp();
272+
$indexTtl = max($ttl, 600);
273273

274-
// Keep index fresh slightly longer than cache TTL to ensure cleanup
275-
$store->put($indexKey, $keys, max($ttl, 600));
274+
foreach (array_unique($tags) as $tag) {
275+
$indexKey = $this->getTagStorageKey($tag);
276+
$entries = $store->get($indexKey, []);
277+
$entries = $this->pruneExpiredIndexEntries($entries);
278+
$entries[$cacheKey] = $expiry;
279+
280+
$store->put($indexKey, $entries, $indexTtl);
281+
}
276282
} catch (\Exception $e) {
277283
Log::debug('API cache index write failed', [
278284
'key' => $cacheKey,
@@ -291,42 +297,66 @@ protected function indexCacheKey($store, $cacheKey, array $tags, $ttl)
291297
*/
292298
protected function flushIndexedKeys($store, array $tags)
293299
{
300+
$invalidated = false;
301+
294302
try {
295-
$indexKey = $this->getTagIndexKey($tags);
296-
$keys = $store->get($indexKey, []);
303+
foreach (array_unique($tags) as $tag) {
304+
$indexKey = $this->getTagStorageKey($tag);
305+
$entries = $store->get($indexKey, []);
297306

298-
if (empty($keys)) {
299-
return false;
300-
}
307+
if (empty($entries)) {
308+
continue;
309+
}
301310

302-
foreach (array_keys($keys) as $cacheKey) {
303-
$store->forget($cacheKey);
304-
}
311+
foreach (array_keys($entries) as $cacheKey) {
312+
$store->forget($cacheKey);
313+
}
305314

306-
$store->forget($indexKey);
307-
308-
return true;
315+
$store->forget($indexKey);
316+
$invalidated = true;
317+
}
309318
} catch (\Exception $e) {
310319
Log::debug('API cache index flush failed', [
311320
'tags' => $tags,
312321
'error' => $e->getMessage(),
313322
]);
314323
}
315324

316-
return false;
325+
return $invalidated;
317326
}
318327

319328
/**
320-
* Build deterministic index key for a given tag list.
329+
* Build deterministic storage key for a given tag.
321330
*
322-
* @param array $tags
331+
* @param string $tag
323332
* @return string
324333
*/
325-
protected function getTagIndexKey(array $tags)
334+
protected function getTagStorageKey($tag)
326335
{
327-
sort($tags);
336+
return self::CACHE_PREFIX . 'tag_index:' . md5($tag);
337+
}
328338

329-
return self::CACHE_PREFIX . 'tag_index:' . md5(implode('|', $tags));
339+
/**
340+
* Remove expired cache key references from the index.
341+
*
342+
* @param array $entries
343+
* @return array
344+
*/
345+
protected function pruneExpiredIndexEntries(array $entries)
346+
{
347+
if (empty($entries)) {
348+
return $entries;
349+
}
350+
351+
$now = now()->getTimestamp();
352+
353+
foreach ($entries as $cacheKey => $timestamp) {
354+
if ($timestamp <= $now) {
355+
unset($entries[$cacheKey]);
356+
}
357+
}
358+
359+
return $entries;
330360
}
331361

332362
/**
@@ -415,29 +445,160 @@ protected function getCacheTags($request)
415445
{
416446
$tags = [];
417447

418-
// Add route-based tag
419448
$route = $request->route();
420449
if ($route && $route->getName()) {
421450
$tags[] = 'route:' . $route->getName();
422451
}
423452

424-
// Add path-based tag
425-
$pathSegments = explode('/', trim($request->path(), '/'));
426-
if (! empty($pathSegments[0])) {
427-
$tags[] = 'path:' . $pathSegments[0];
428-
}
453+
$tags = array_merge($tags, $this->buildDynamicPathTags($request));
429454

430-
// Add custom tags from route
431455
if ($route) {
432456
$customTags = $route->getAction('cache_tags');
433457
if (is_array($customTags)) {
434458
$tags = array_merge($tags, $customTags);
435459
}
436460
}
437461

462+
$tags = array_values(array_unique(array_filter($tags)));
463+
438464
return $tags;
439465
}
440466

467+
/**
468+
* Build dynamic cache tags derived from the request path.
469+
*
470+
* @param \Illuminate\Http\Request $request
471+
* @return array
472+
*/
473+
protected function buildDynamicPathTags($request)
474+
{
475+
if (! config('laravel-page-speed.api.cache.dynamic_tagging.enabled', true)) {
476+
$fallbackSegment = Str::lower(trim($request->segment(1)));
477+
478+
return $fallbackSegment ? ['path:' . $fallbackSegment] : [];
479+
}
480+
481+
$segments = $this->getRelevantSegments($request);
482+
483+
if (empty($segments)) {
484+
return [];
485+
}
486+
487+
$maxDepth = (int) config('laravel-page-speed.api.cache.dynamic_tagging.max_depth', 5);
488+
if ($maxDepth > 0) {
489+
$segments = array_slice($segments, 0, $maxDepth);
490+
}
491+
492+
$tags = [];
493+
494+
// Preserve compatibility with legacy path tag
495+
$tags[] = 'path:' . $segments[0];
496+
$tags[] = 'collection:' . $segments[0];
497+
498+
// Cumulative tags with raw segments
499+
$cumulative = [];
500+
foreach ($segments as $segment) {
501+
$cumulative[] = $segment;
502+
$tags[] = 'resource:' . implode(':', $cumulative);
503+
}
504+
505+
$normalizedSegments = $this->normalizeSegments($segments);
506+
$normalizedCumulative = [];
507+
foreach ($normalizedSegments as $segment) {
508+
$normalizedCumulative[] = $segment;
509+
$tags[] = 'resource:' . implode(':', $normalizedCumulative);
510+
}
511+
512+
$tags[] = 'fqn:' . implode('/', $segments);
513+
$tags[] = 'fqn:' . implode('/', $normalizedSegments);
514+
515+
return array_values(array_unique($tags));
516+
}
517+
518+
/**
519+
* Filter request segments to relevant portions for tagging.
520+
*
521+
* @param \Illuminate\Http\Request $request
522+
* @return array
523+
*/
524+
protected function getRelevantSegments($request)
525+
{
526+
$segments = array_values(array_filter(explode('/', trim($request->path(), '/')), 'strlen'));
527+
528+
if (empty($segments)) {
529+
return [];
530+
}
531+
532+
$ignore = array_map('strtolower', config('laravel-page-speed.api.cache.dynamic_tagging.ignore_segments', ['api']));
533+
534+
$segments = array_values(array_filter($segments, function ($segment) use ($ignore) {
535+
return ! in_array(strtolower($segment), $ignore, true);
536+
}));
537+
538+
return array_map(function ($segment) {
539+
return Str::lower($segment);
540+
}, $segments);
541+
}
542+
543+
/**
544+
* Normalize path segments by replacing identifiers with placeholders when enabled.
545+
*
546+
* @param array $segments
547+
* @return array
548+
*/
549+
protected function normalizeSegments(array $segments)
550+
{
551+
if (! config('laravel-page-speed.api.cache.dynamic_tagging.normalize_ids', true)) {
552+
return $segments;
553+
}
554+
555+
return array_map(function ($segment) {
556+
return $this->normalizeSegment($segment);
557+
}, $segments);
558+
}
559+
560+
/**
561+
* Normalize a single segment.
562+
*
563+
* @param string $segment
564+
* @return string
565+
*/
566+
protected function normalizeSegment($segment)
567+
{
568+
return $this->isIdentifierSegment($segment) ? '{id}' : $segment;
569+
}
570+
571+
/**
572+
* Determine if the segment represents an identifier.
573+
*
574+
* @param string $segment
575+
* @return bool
576+
*/
577+
protected function isIdentifierSegment($segment)
578+
{
579+
if ($segment === '') {
580+
return false;
581+
}
582+
583+
if (preg_match('/^\d+$/', $segment)) {
584+
return true;
585+
}
586+
587+
if (preg_match('/^[0-9a-f]{24}$/i', $segment) || preg_match('/^[0-9a-f]{32}$/i', $segment) || preg_match('/^[0-9a-f]{40}$/i', $segment)) {
588+
return true;
589+
}
590+
591+
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $segment)) {
592+
return true;
593+
}
594+
595+
if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/', strtoupper($segment))) { // ULID support
596+
return true;
597+
}
598+
599+
return false;
600+
}
601+
441602
/**
442603
* Determine if the request should be cached.
443604
*

0 commit comments

Comments
 (0)