Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions app/Http/Controllers/ShowDocumentationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);

Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
81 changes: 81 additions & 0 deletions tests/Feature/Docs/DocsCachingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] = '<p>Served from the cache</p>';
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<string, mixed>
*/
private function stalePageProperties(): array
{
return [
'platform' => 'mobile',
'version' => '4',
'pagePath' => 'docs/mobile/4/edge-components/stack',
'title' => 'Stale',
'content' => '<p>Stale content</p>',
'tableOfContents' => [],
'navigation' => '',
'editUrl' => '',
'nextPage' => null,
'previousPage' => null,
];
}
}