From d8bb52697409327dcf1ab18ec2184087e320c019 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 3 Sep 2026 09:59:48 +0100 Subject: [PATCH] Rebuild the docs cache when the markdown behind it changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs pages and navigation were cached for a day with no link back to the files they were rendered from. Deploys ship new markdown without clearing the application cache, so an edited page — and the sidebar rendered alongside it — could trail the actual files by up to 24 hours. Each cache entry now carries a fingerprint of the markdown it was built from (relative path plus mtime for every .md file in the version, no file contents read) and is rebuilt as soon as that stops matching. Entries written before the fingerprint existed have none, so they miss and rebuild on first request. Co-Authored-By: Claude Opus 5 (1M context) --- .../ShowDocumentationController.php | 50 ++++++++++-- tests/Feature/Docs/DocsCachingTest.php | 81 +++++++++++++++++++ 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/ShowDocumentationController.php b/app/Http/Controllers/ShowDocumentationController.php index 5936f4a8f..50a0c5482 100644 --- a/app/Http/Controllers/ShowDocumentationController.php +++ b/app/Http/Controllers/ShowDocumentationController.php @@ -30,7 +30,9 @@ public function __invoke(Request $request, string $platform, string $version, ?s session(['viewing_docs_version' => $version]); session(['viewing_docs_platform' => $platform]); - $navigation = $this->cacheOrCompute("docs_nav_{$platform}_{$version}", + $fingerprint = $this->fingerprint($platform, $version); + + $navigation = $this->cacheOrCompute("docs_nav_{$platform}_{$version}", $fingerprint, fn () => $this->getNavigation($platform, $version) ); @@ -39,7 +41,7 @@ public function __invoke(Request $request, string $platform, string $version, ?s } try { - $pageProperties = $this->cacheOrCompute("docs_{$platform}_{$version}_{$page}", + $pageProperties = $this->cacheOrCompute("docs_{$platform}_{$version}_{$page}", $fingerprint, fn () => $this->getPageProperties($platform, $version, $page) ); } catch (InvalidArgumentException $e) { @@ -85,18 +87,50 @@ public function __invoke(Request $request, string $platform, string $version, ?s * docs edits show up immediately without clearing (or racing on) the cache. * The key folds in `config('docs')` so a Jump version bump invalidates * rendered pages instead of trailing by up to a day. + * + * The entry also carries a fingerprint of the markdown it was built from + * and is rebuilt as soon as that stops matching. Deploys ship new docs + * without clearing the application cache, so the TTL alone leaves an + * edited page — and the sidebar rendered alongside it — up to a day behind + * the files. Entries written before the fingerprint existed have none, so + * they miss and rebuild on the first request. */ - private function cacheOrCompute(string $key, Closure $callback): mixed + private function cacheOrCompute(string $key, string $fingerprint, Closure $callback): mixed { if (config('app.env') === 'local') { return $callback(); } - return Cache::remember( - $key.'_'.substr(md5(serialize(config('docs'))), 0, 8), - now()->addDay(), - $callback - ); + $key = $key.'_'.substr(md5(serialize(config('docs'))), 0, 8); + + $cached = Cache::get($key); + + if (Arr::get($cached, 'fingerprint') === $fingerprint) { + return $cached['value']; + } + + $value = $callback(); + + Cache::put($key, ['fingerprint' => $fingerprint, 'value' => $value], now()->addDay()); + + return $value; + } + + /** + * A signature of every markdown file behind a platform's version — names + * and modification times, without reading any content. + */ + private function fingerprint(string $platform, string $version): string + { + $files = (new Finder) + ->files() + ->name('*.md') + ->in(resource_path("views/docs/{$platform}/{$version}")); + + return md5(collect($files) + ->map(fn (SplFileInfo $file): string => $file->getRelativePathname().'@'.$file->getMTime()) + ->sort() + ->implode('|')); } public function serveRawMarkdown(Request $request, string $platform, string $version, string $page) diff --git a/tests/Feature/Docs/DocsCachingTest.php b/tests/Feature/Docs/DocsCachingTest.php index 5ab784f2e..2c08e658d 100644 --- a/tests/Feature/Docs/DocsCachingTest.php +++ b/tests/Feature/Docs/DocsCachingTest.php @@ -60,4 +60,85 @@ public function test_non_local_docs_request_caches_page_properties(): void $this->assertTrue(Cache::has($key)); } + + public function test_page_cache_is_reused_while_the_markdown_is_unchanged(): void + { + config(['app.env' => 'production']); + + $this->get('/docs/mobile/4/edge-components/stack')->assertStatus(200); + + $entry = Cache::get($this->pageCacheKey()); + $entry['value']['content'] = '

Served from the cache

'; + Cache::put($this->pageCacheKey(), $entry, now()->addDay()); + + $this->get('/docs/mobile/4/edge-components/stack') + ->assertStatus(200) + ->assertSee('Served from the cache', false); + } + + public function test_page_cache_is_rebuilt_once_the_markdown_changes(): void + { + config(['app.env' => 'production']); + + Cache::put($this->pageCacheKey(), [ + 'fingerprint' => 'built-from-older-markdown', + 'value' => $this->stalePageProperties(), + ], now()->addDay()); + + $this->get('/docs/mobile/4/edge-components/stack') + ->assertStatus(200) + ->assertDontSee('Stale content'); + } + + public function test_page_cached_before_fingerprinting_is_rebuilt(): void + { + config(['app.env' => 'production']); + + Cache::put($this->pageCacheKey(), $this->stalePageProperties(), now()->addDay()); + + $this->get('/docs/mobile/4/edge-components/stack') + ->assertStatus(200) + ->assertDontSee('Stale content'); + } + + public function test_navigation_cached_before_fingerprinting_is_rebuilt(): void + { + config(['app.env' => 'production']); + + $key = 'docs_nav_mobile_4_'.substr(md5(serialize(config('docs'))), 0, 8); + Cache::put($key, [['path' => '/docs/mobile/4/removed-page', 'title' => 'Removed', 'order' => 0]], now()->addDay()); + + $response = $this->get('/docs/mobile/4'); + + $response->assertStatus(301); + $this->assertStringNotContainsString('removed-page', (string) $response->headers->get('Location')); + } + + private function pageCacheKey(): string + { + return 'docs_mobile_4_edge-components/stack_'.substr(md5(serialize(config('docs'))), 0, 8); + } + + /** + * Page properties in the shape the view expects, so a cache entry that is + * wrongly trusted renders rather than erroring — the assertion, not an + * exception, is what reports the failure. + * + * @return array + */ + private function stalePageProperties(): array + { + return [ + 'platform' => 'mobile', + 'version' => '4', + 'pagePath' => 'docs/mobile/4/edge-components/stack', + 'title' => 'Stale', + 'content' => '

Stale content

', + 'tableOfContents' => [], + 'navigation' => '', + 'editUrl' => '', + 'nextPage' => null, + 'previousPage' => null, + ]; + } }