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, + ]; + } }