From 705a115addf6f6015750823ce6724fe885bf5ee8 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sun, 31 May 2026 23:40:19 +0900 Subject: [PATCH 1/4] Add failing diamond-dependency test (reproduces multi-embed bug) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A #[Cacheable] resource that embeds more than one child (DiamondTop embeds both DiamondLeft and DiamondRight, which both embed the shared DiamondBottom) is not handled: CacheDependency::depends() overwrites the parent's Surrogate-Key and asserts the parent has none yet. The build aborts (assertion) / loses all but the last child's dependency, so this test currently FAILS — it documents the bug before the fix in the next commit. --- tests/ComplexCacheDependencyTest.php | 115 ++++++++++++++++++ .../src/Resource/Page/Dep/DiamondBottom.php | 17 +++ .../src/Resource/Page/Dep/DiamondLeft.php | 19 +++ .../src/Resource/Page/Dep/DiamondRight.php | 19 +++ .../src/Resource/Page/Dep/DiamondTop.php | 20 +++ 5 files changed, 190 insertions(+) create mode 100644 tests/ComplexCacheDependencyTest.php create mode 100644 tests/Fake/fake-app/src/Resource/Page/Dep/DiamondBottom.php create mode 100644 tests/Fake/fake-app/src/Resource/Page/Dep/DiamondLeft.php create mode 100644 tests/Fake/fake-app/src/Resource/Page/Dep/DiamondRight.php create mode 100644 tests/Fake/fake-app/src/Resource/Page/Dep/DiamondTop.php diff --git a/tests/ComplexCacheDependencyTest.php b/tests/ComplexCacheDependencyTest.php new file mode 100644 index 00000000..400354ad --- /dev/null +++ b/tests/ComplexCacheDependencyTest.php @@ -0,0 +1,115 @@ +resource = $injector->getInstance(ResourceInterface::class); + $this->repository = $injector->getInstance(QueryRepositoryInterface::class); + + parent::setUp(); + } + + private function isCached(string $name): bool + { + return $this->repository->get(new Uri('page://self/dep/' . $name)) instanceof ResourceState; + } + + public function testDiamondBuildsEveryNode(): void + { + $this->resource->get('page://self/dep/diamond-top'); + + foreach (['diamond-top', 'diamond-left', 'diamond-right', 'diamond-bottom'] as $name) { + $this->assertTrue($this->isCached($name), $name . ' should be cached after the build'); + } + } + + public function testMultiEmbedParentDependsOnEveryChild(): void + { + // Regression: diamond-top embeds BOTH arms. Purging EITHER arm must invalidate top. + // Before the fix, the second embed overwrote the first, so one arm's dependency was lost. + $this->resource->get('page://self/dep/diamond-top'); + $this->repository->purge(new Uri('page://self/dep/diamond-left')); + $this->assertFalse($this->isCached('diamond-top'), 'top must depend on its LEFT child'); + + $this->resource->get('page://self/dep/diamond-top'); // rebuild + $this->repository->purge(new Uri('page://self/dep/diamond-right')); + $this->assertFalse($this->isCached('diamond-top'), 'top must also depend on its RIGHT child'); + } + + public function testPurgingSharedLeafCascadesThroughBothPaths(): void + { + $this->resource->get('page://self/dep/diamond-top'); + + // diamond-bottom is embedded via both arms; purging it invalidates every ancestor. + $this->repository->purge(new Uri('page://self/dep/diamond-bottom')); + + $this->assertFalse($this->isCached('diamond-top'), 'top reaches bottom via both arms'); + $this->assertFalse($this->isCached('diamond-left'), 'left embeds bottom'); + $this->assertFalse($this->isCached('diamond-right'), 'right embeds bottom'); + } + + public function testPurgingOneArmIsPreciseAndDoesNotOverReach(): void + { + $this->resource->get('page://self/dep/diamond-top'); + + // Purge only the left arm. + $this->repository->purge(new Uri('page://self/dep/diamond-left')); + + $this->assertFalse($this->isCached('diamond-top'), 'top embeds left -> invalidated'); + $this->assertTrue($this->isCached('diamond-right'), 'the right arm is independent of left'); + $this->assertTrue($this->isCached('diamond-bottom'), 'the shared leaf survives an arm purge'); + } + + public function testRebuildAfterPurgeRePropagatesDependencies(): void + { + $this->resource->get('page://self/dep/diamond-top'); + $this->repository->purge(new Uri('page://self/dep/diamond-bottom')); + $this->assertFalse($this->isCached('diamond-top'), 'cascade invalidated top'); + + // Rebuild and confirm the dependency graph is restored (not a one-shot). + $this->resource->get('page://self/dep/diamond-top'); + $this->assertTrue($this->isCached('diamond-top')); + $this->repository->purge(new Uri('page://self/dep/diamond-bottom')); + $this->assertFalse($this->isCached('diamond-top'), 'cascade still works after a rebuild'); + } + + public function testUnrelatedResourceIsUnaffectedByDiamondPurges(): void + { + $this->resource->get('page://self/dep/diamond-top'); + $this->resource->get('page://self/dep/child-c'); // unrelated chain + + $this->repository->purge(new Uri('page://self/dep/diamond-bottom')); + + $this->assertTrue($this->isCached('child-c'), 'an unrelated resource must not be touched'); + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondBottom.php b/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondBottom.php new file mode 100644 index 00000000..effa9d20 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondBottom.php @@ -0,0 +1,17 @@ + 1]; + + public function onGet() + { + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondLeft.php b/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondLeft.php new file mode 100644 index 00000000..184f4de1 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondLeft.php @@ -0,0 +1,19 @@ + 1]; + + #[Embed(rel: 'bottom', src: '/dep/diamond-bottom')] + public function onGet() + { + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondRight.php b/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondRight.php new file mode 100644 index 00000000..8923ad7c --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondRight.php @@ -0,0 +1,19 @@ + 1]; + + #[Embed(rel: 'bottom', src: '/dep/diamond-bottom')] + public function onGet() + { + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondTop.php b/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondTop.php new file mode 100644 index 00000000..d9fa950f --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/Dep/DiamondTop.php @@ -0,0 +1,20 @@ + 1]; + + #[Embed(rel: 'left', src: '/dep/diamond-left')] + #[Embed(rel: 'right', src: '/dep/diamond-right')] + public function onGet() + { + return $this; + } +} From eb457bea60b5ad15bcd156b1a3e34332738468e6 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sun, 31 May 2026 23:41:12 +0900 Subject: [PATCH 2/4] Fix multi-embed cache dependency losing all but the last child CacheDependency::depends() overwrote the parent's Surrogate-Key (and asserted the parent had none yet), so a #[Cacheable] resource that embeds more than one child kept only the LAST child's dependency. Purging an earlier child then failed to invalidate the parent, which kept serving a stale page (under-invalidation); with assertions enabled it threw instead and the page was not cached at all. Accumulate the child tags across every embedded child and drop the assertion. Turns the diamond-dependency test from the previous commit green. --- src/CacheDependency.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/CacheDependency.php b/src/CacheDependency.php index e1fb9a1b..eb79aba2 100644 --- a/src/CacheDependency.php +++ b/src/CacheDependency.php @@ -7,7 +7,6 @@ use BEAR\Resource\ResourceObject; use Override; -use function assert; use function sprintf; final readonly class CacheDependency implements CacheDependencyInterface @@ -20,14 +19,17 @@ public function __construct( #[Override] public function depends(ResourceObject $from, ResourceObject $to): void { - assert(! isset($from->headers[Header::SURROGATE_KEY])); - - $cacheDependencyTags = ($this->uriTag)($to->uri); + $childTags = ($this->uriTag)($to->uri); if (isset($to->headers[Header::SURROGATE_KEY])) { - $cacheDependencyTags .= sprintf(' %s', $to->headers[Header::SURROGATE_KEY]); + $childTags .= sprintf(' %s', $to->headers[Header::SURROGATE_KEY]); unset($to->headers[Header::SURROGATE_KEY]); } - $from->headers[Header::SURROGATE_KEY] = $cacheDependencyTags; + // Accumulate across every embedded child: a resource that embeds more than one + // child must depend on all of them, not only the last. Overwriting here used to + // silently drop earlier children's dependencies (stale-cache bug). + $from->headers[Header::SURROGATE_KEY] = isset($from->headers[Header::SURROGATE_KEY]) + ? sprintf('%s %s', $from->headers[Header::SURROGATE_KEY], $childTags) + : $childTags; } } From 934ce8a91cdc1da22dd3f5de5fca226400590353 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sun, 31 May 2026 23:45:47 +0900 Subject: [PATCH 3/4] Add [Unreleased] changelog entry for multi-embed cache dependency fix --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e56e1532..38e69b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- Fix multi-embed cache dependency: a `#[Cacheable]` resource embedding more than one child kept only the last child's dependency, so purging an earlier child failed to invalidate the parent (stale cache). `CacheDependency::depends()` now accumulates child tags instead of overwriting, and the erroneous assertion that a parent had no prior tags has been removed. + ## [1.16.0] - 2026-05-16 ### Fixed From c02dc5aa9a91b5e0001d7ef95b86488b0df06b42 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 1 Jun 2026 01:50:57 +0900 Subject: [PATCH 4/4] Set release version 1.16.1 (2026-06-01) for the multi-embed dependency fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e69b69..1cb12a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.16.1] - 2026-06-01 ### Fixed - Fix multi-embed cache dependency: a `#[Cacheable]` resource embedding more than one child kept only the last child's dependency, so purging an earlier child failed to invalidate the parent (stale cache). `CacheDependency::depends()` now accumulates child tags instead of overwriting, and the erroneous assertion that a parent had no prior tags has been removed.