From 157e773ef26093b55eaf36a4f9139e58362baeed Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 09:07:33 +0900 Subject: [PATCH 01/39] Fix #255: query-variant cache entries survive a bare-URI purge #[Purge]'s own URI and MatchQuery-based auto-purge target the bare/ required-only form of a GET's URI (default-valued onGet() parameters and any query it doesn't declare are dropped). The tag UriTag computes for a cached entry, however, is built from every query parameter that was present, so `?page=2` never matched the tag a purge aimed at the bare URI emitted - the variant stayed stale. Add BaseUriTagInterface/BaseUriTag, which mirrors MatchQuery's view of onGet() to compute that same coarser tag, and tag every cache write with it in addition to the full-query tag: - ResourceStorage::getTags - #[Cacheable] value cache (roPool) - QueryRepository::doPut - etagPool's If-None-Match validator, which computed its own tag list independently of getTags() - DonutRepository::templateKeys - #[CacheableResponse] page storage tags (roPool) - SurrogateKeys::addRawTag, wired into doPutStatic/doPutDonut - the CDN-facing Surrogate-Key header Trade-off (documented in CHANGELOG): every query variant now shares the coarser tag, so a write purges all of them together - editing ?page=1 also drops the still-unchanged ?page=2 entry. Verified: reverting the 4 src/ call sites turns all 5 new regression tests red (confirmed by running them); restoring turns them green. phpstan/psalm/phpunit error counts are byte-identical to baseline (zero new findings). --- CHANGELOG.md | 5 + src/BaseUriTag.php | 51 +++++++ src/BaseUriTagInterface.php | 20 +++ src/DonutRepository.php | 23 +++- src/QueryRepository.php | 10 ++ src/QueryRepositoryModule.php | 3 +- src/ResourceStorage.php | 9 +- src/SurrogateKeys.php | 6 + .../src/Resource/App/OptionalParam.php | 2 + .../fake-app/src/Resource/Page/PagedList.php | 30 ++++ tests/QueryVariantPurgeTest.php | 130 ++++++++++++++++++ 11 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 src/BaseUriTag.php create mode 100644 src/BaseUriTagInterface.php create mode 100644 tests/Fake/fake-app/src/Resource/Page/PagedList.php create mode 100644 tests/QueryVariantPurgeTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e461090d..30496b85 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 +- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with the coarser tag that `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body, the etagPool's If-None-Match validator, and the CDN `Surrogate-Key` header alike (#255). Trade-off: every query variant now shares that coarser tag, so a write purges all of them together - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. + ## [1.18.0] - 2026-10-29 ### Changed diff --git a/src/BaseUriTag.php b/src/BaseUriTag.php new file mode 100644 index 00000000..aff121aa --- /dev/null +++ b/src/BaseUriTag.php @@ -0,0 +1,51 @@ +uri->query; + $baseQuery = []; + foreach ((new ReflectionMethod($ro, 'onGet'))->getParameters() as $parameter) { + if ($parameter->isDefaultValueAvailable() || ! isset($query[$parameter->name])) { + continue; + } + + /** @psalm-suppress MixedAssignment */ + $baseQuery[$parameter->name] = $query[$parameter->name]; + } + + $baseUri = clone $ro->uri; + $baseUri->query = $baseQuery; + + return ($this->uriTag)($baseUri); + } +} diff --git a/src/BaseUriTagInterface.php b/src/BaseUriTagInterface.php new file mode 100644 index 00000000..944d7a79 --- /dev/null +++ b/src/BaseUriTagInterface.php @@ -0,0 +1,20 @@ +cacheDependency = $cacheDependency ?? new CacheDependency($this->uriTag, $this->logger); + // Same UriTag as $this->uriTag, so a custom binding's tag format stays consistent. + $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); } #[Override] @@ -148,6 +152,7 @@ private function doPutStatic(ResourceObject $ro, int|null $ttl, int|null $sMaxAg $this->logger->event(new PutDonutContext((string) $ro->uri, $ttl, $sMaxAge)); $keys = new SurrogateKeys($ro->uri); $keys->addTag($ro); + $this->addBaseTag($keys, $ro); $declaredKeys = $this->getHeaderKeys($ro); $templateKeys = $this->templateKeys($ro, $declaredKeys); $donut = ResourceDonut::create($ro, $this->renderer, $keys, $sMaxAge, true)->withStorageState($ttl, $templateKeys); @@ -174,6 +179,7 @@ private function doPutDonut(ResourceObject $ro, int|null $donutTtl): void $this->classifyMaterials($ro); $this->logger->event(new PutDonutContext((string) $ro->uri, $donutTtl, null)); $keys = new SurrogateKeys($ro->uri); + $this->addBaseTag($keys, $ro); $declaredKeys = $this->getHeaderKeys($ro); $templateKeys = $this->templateKeys($ro, $declaredKeys); $donut = ResourceDonut::create($ro, $this->renderer, $keys, $donutTtl, false)->withStorageState($donutTtl, $templateKeys); @@ -238,7 +244,22 @@ private function classifyMaterials(ResourceObject $ro): void */ private function templateKeys(ResourceObject $ro, array $declaredKeys): array { - return array_values(array_unique([($this->uriTag)($ro->uri), ...$declaredKeys])); + $baseTag = ($this->baseUriTag)($ro); + $baseTags = $baseTag === null ? [] : [$baseTag]; + + return array_values(array_unique([($this->uriTag)($ro->uri), ...$baseTags, ...$declaredKeys])); + } + + /** + * #255: a coarser tag (see BaseUriTag) on the same SurrogateKeys instance that feeds both + * the Surrogate-Key header templateKeys() mirrors for roPool/etagPool storage. + */ + private function addBaseTag(SurrogateKeys $keys, ResourceObject $ro): void + { + $baseTag = ($this->baseUriTag)($ro); + if ($baseTag !== null) { + $keys->addRawTag($baseTag); + } } /** diff --git a/src/QueryRepository.php b/src/QueryRepository.php index ffd4af23..928a8724 100644 --- a/src/QueryRepository.php +++ b/src/QueryRepository.php @@ -29,6 +29,7 @@ use function sprintf; use function strtotime; use function time; +use function trim; /** @internal */ final readonly class QueryRepository implements QueryRepositoryInterface @@ -41,6 +42,7 @@ public function __construct( private Expiry $expiry, private CacheDependencyInterface $cacheDependency, private DeclaredCacheControl $declaredCacheControl = new DeclaredCacheControl(), + private BaseUriTagInterface $baseUriTag = new BaseUriTag(), ) { } @@ -102,6 +104,14 @@ private function doPut(ResourceObject $ro): bool if (isset($ro->headers[Header::ETAG])) { $etag = $ro->headers[Header::ETAG]; $surrogateKeys = $ro->headers[Header::SURROGATE_KEY] ?? ''; + // #255: the etagPool's validator is scoped by saveEtag()'s own tag list, which + // ResourceStorage::getTags() (the roPool's body entry) never feeds - without this, + // a bare-URI purge invalidates the body but leaves a stale If-None-Match validator. + $baseTag = ($this->baseUriTag)($ro); + if ($baseTag !== null) { + $surrogateKeys = trim($surrogateKeys . ' ' . $baseTag); + } + $this->storage->saveEtag($ro->uri, $etag, $surrogateKeys, $ttl); } diff --git a/src/QueryRepositoryModule.php b/src/QueryRepositoryModule.php index 81c680a3..7db5658d 100644 --- a/src/QueryRepositoryModule.php +++ b/src/QueryRepositoryModule.php @@ -34,7 +34,7 @@ * SemanticLoggerInterface (null by default - install Dev/ProdQueryRepositoryLogModule to record) * MatchQueryInterface * UriTag - * MatchQueryInterface + * BaseUriTagInterface * DeclaredCacheControl * * The following module are installed. @@ -73,6 +73,7 @@ protected function configure(): void $this->bind(EtagSetterInterface::class)->to(EtagSetter::class); $this->bind(NamedParameterInterface::class)->to(NamedParameter::class); $this->bind(ResourceStorageInterface::class)->to(ResourceStorage::class)->in(Scope::SINGLETON); + $this->bind(BaseUriTagInterface::class)->to(BaseUriTag::class); $this->bind(MatchQueryInterface::class)->to(MatchQuery::class); $this->bind(RefreshAnnotatedCommand::class); $this->bind(RefreshSameCommand::class); diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php index 1709d0a9..0b6154da 100644 --- a/src/ResourceStorage.php +++ b/src/ResourceStorage.php @@ -92,6 +92,7 @@ public function __construct( #[Set(TagAwareAdapterInterface::class, EtagPool::class)] ProviderInterface $etagPoolProvider, private ResourceBodyEvaluator $evaluateBody = new ResourceBodyEvaluator(), + private BaseUriTagInterface $baseUriTag = new BaseUriTag(), ) { $this->initializePools($roPoolProvider, $etagPoolProvider); } @@ -338,8 +339,13 @@ private function getTags(ResourceObject $ro, array|null $surrogateKeys = null): // as a tag produced one non-volatile tag Set per content version that, under a // volatile-* eviction policy, is never reclaimed - leaking memory without being read. $surrogateKeys ??= isset($ro->headers[Header::SURROGATE_KEY]) ? explode(' ', $ro->headers[Header::SURROGATE_KEY]) : []; + // #255: the base tag drops onGet()'s default-valued/unset query params, matching what + // #[Purge]/#[Refresh] and MatchQuery-based auto-purge target, so a query-variant entry + // (?page=2) is reachable by a purge aimed at the bare URI. + $baseTag = ($this->baseUriTag)($ro); + $baseTags = $baseTag === null ? [] : [$baseTag]; /** @var list $uniqueTags */ - $uniqueTags = array_values(array_unique([($this->uriTag)($ro->uri), ...$surrogateKeys])); + $uniqueTags = array_values(array_unique([($this->uriTag)($ro->uri), ...$baseTags, ...$surrogateKeys])); return $uniqueTags; } @@ -441,6 +447,7 @@ public function __unserialize(array $data): void // Stateless, so rebuilt rather than carried: left out of the payload it stays // uninitialized, and the first save after a compiled-injector restore throws. $this->evaluateBody = new ResourceBodyEvaluator(); + $this->baseUriTag = new BaseUriTag($this->uriTag); $this->initializePools($data['roProvider'], $data['etagProvider']); } } diff --git a/src/SurrogateKeys.php b/src/SurrogateKeys.php index f9ff9a42..57e29beb 100644 --- a/src/SurrogateKeys.php +++ b/src/SurrogateKeys.php @@ -25,6 +25,12 @@ public function __construct(AbstractUri $uri) $this->surrogateKeys = [($this->uriTag)($uri)]; } + /** #255: inject a precomputed tag (BaseUriTag's output) directly, bypassing UriTag. */ + public function addRawTag(string $tag): void + { + $this->surrogateKeys[] = $tag; + } + /** * Add etag of embedded resource */ diff --git a/tests/Fake/fake-app/src/Resource/App/OptionalParam.php b/tests/Fake/fake-app/src/Resource/App/OptionalParam.php index b367a1df..a283b4ec 100644 --- a/tests/Fake/fake-app/src/Resource/App/OptionalParam.php +++ b/tests/Fake/fake-app/src/Resource/App/OptionalParam.php @@ -7,6 +7,7 @@ namespace FakeVendor\HelloWorld\Resource\App; use BEAR\RepositoryModule\Annotation\Cacheable; +use BEAR\RepositoryModule\Annotation\Purge; use BEAR\Resource\ResourceObject; #[Cacheable] @@ -24,6 +25,7 @@ public function onGet(int $id, int $page = 1, int $sort = 0) return $this; } + #[Purge(uri: 'app://self/optional-param?id={id}')] public function onPut(int $id) { return $this; diff --git a/tests/Fake/fake-app/src/Resource/Page/PagedList.php b/tests/Fake/fake-app/src/Resource/Page/PagedList.php new file mode 100644 index 00000000..d380987c --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/PagedList.php @@ -0,0 +1,30 @@ +body = ['page' => $page, 'time' => \microtime(true)]; + + return $this; + } + + public function onPost(string $title) + { + return $this; + } +} diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php new file mode 100644 index 00000000..9cb50395 --- /dev/null +++ b/tests/QueryVariantPurgeTest.php @@ -0,0 +1,130 @@ +resource = $injector->getInstance(ResourceInterface::class); + $this->repository = $injector->getInstance(QueryRepositoryInterface::class); + $this->storage = $injector->getInstance(ResourceStorageInterface::class); + } + + private function time(string $uri): float + { + $body = $this->resource->get($uri)->body; + assert(is_array($body)); + $time = $body['time']; + assert(is_float($time)); + + return $time; + } + + /** A query onGet() does not declare at all (not merely defaulted). */ + private function timeWithExtraQuery(): float + { + $request = $this->resource->get->uri('app://self/user')->withQuery(['id' => 1])->addQuery(['limit' => 5]); // @phpstan-ignore-line + assert($request instanceof AbstractRequest); + $ro = $request->eager->request(); + assert($ro instanceof ResourceObject); + $body = $ro->body; + assert(is_array($body)); + $time = $body['time']; + assert(is_float($time)); + + return $time; + } + + public function testPurgeAttributeReachesDefaultValuedQueryVariant(): void + { + $bare = $this->time('app://self/optional-param?id=1'); + $page2 = $this->time('app://self/optional-param?id=1&page=2'); + $this->assertSame($bare, $this->time('app://self/optional-param?id=1'), 'precondition: warm cache'); + $this->assertSame($page2, $this->time('app://self/optional-param?id=1&page=2'), 'precondition: warm cache'); + + $this->resource->put('app://self/optional-param', ['id' => 1]); + + $this->assertNotSame($bare, $this->time('app://self/optional-param?id=1')); + $this->assertNotSame($page2, $this->time('app://self/optional-param?id=1&page=2')); + } + + public function testDirectPurgeReachesDefaultValuedQueryVariant(): void + { + $bare = $this->time('app://self/optional-param?id=1'); + $page2 = $this->time('app://self/optional-param?id=1&page=2'); + $this->assertSame($bare, $this->time('app://self/optional-param?id=1'), 'precondition: warm cache'); + $this->assertSame($page2, $this->time('app://self/optional-param?id=1&page=2'), 'precondition: warm cache'); + + $this->repository->purge(new Uri('app://self/optional-param?id=1')); + + $this->assertNotSame($bare, $this->time('app://self/optional-param?id=1')); + $this->assertNotSame($page2, $this->time('app://self/optional-param?id=1&page=2')); + } + + /** A query onGet() does not declare at all (not merely defaulted) must drop too. */ + public function testAutoPurgeReachesQueryNotInOnGetSignature(): void + { + $plain = $this->time('app://self/user?id=1'); + $before = $this->timeWithExtraQuery(); + $this->assertSame($plain, $this->time('app://self/user?id=1'), 'precondition: warm cache'); + $this->assertSame($before, $this->timeWithExtraQuery(), 'precondition: warm cache'); + + $this->resource->delete('app://self/user', ['id' => 1]); + + $this->assertNotSame($plain, $this->time('app://self/user?id=1')); + $this->assertNotSame($before, $this->timeWithExtraQuery()); + } + + public function testDonutAutoPurgeReachesDefaultValuedQueryVariant(): void + { + $bare = (string) $this->resource->get('page://self/paged-list'); + $page2 = (string) $this->resource->get('page://self/paged-list?page=2'); + $this->assertSame($bare, (string) $this->resource->get('page://self/paged-list'), 'precondition: warm cache'); + $this->assertSame($page2, (string) $this->resource->get('page://self/paged-list?page=2'), 'precondition: warm cache'); + + $this->resource->post('page://self/paged-list', ['title' => 'new']); + + $this->assertNotSame($bare, (string) $this->resource->get('page://self/paged-list')); + $this->assertNotSame($page2, (string) $this->resource->get('page://self/paged-list?page=2')); + } + + /** The etagPool computes its own tag list (ResourceStorage::saveEtag), independent of + * getTags(); a bare purge must invalidate its If-None-Match validator too, or a + * conditional GET keeps returning 304 for a body the roPool already dropped. */ + public function testBarePurgeInvalidatesQueryVariantEtag(): void + { + assert($this->storage instanceof ScopedValidatorInterface); + $uri = new Uri('app://self/optional-param?id=1&page=2'); + $ro = $this->resource->get('app://self/optional-param?id=1&page=2'); + $etag = $ro->headers['ETag']; + $this->assertTrue($this->storage->hasEtagFor($etag, $uri), 'precondition: validator registered'); + + $this->repository->purge(new Uri('app://self/optional-param?id=1')); + + $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); + } +} From 1fe355996eb60f62d84df43e17b47e41364a14f7 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 09:12:38 +0900 Subject: [PATCH 02/39] Fix PHPMD ExcessiveClassComplexity in DonutRepository (CI) BaseUriTag now falls back to the plain URI tag instead of null when the resource has no onGet(), so every call site drops its null-check branch - array_unique() already collapses the resulting duplicate. This also removes DonutRepository's addBaseTag() helper entirely, pulling its class complexity back under PHPMD's threshold of 50 (was exactly 50 after the first commit; CI job `sa / PHPMD` failed). Verified locally: `composer phpmd` exits 0; phpunit/cs/phpstan/psalm unchanged (489 tests same baseline failures/errors, cs 79/79, phpstan 36 errors byte-identical to baseline, psalm 5 errors byte-identical). --- src/BaseUriTag.php | 4 ++-- src/BaseUriTagInterface.php | 4 ++-- src/DonutRepository.php | 21 +++------------------ src/QueryRepository.php | 5 +---- src/ResourceStorage.php | 4 +--- 5 files changed, 9 insertions(+), 29 deletions(-) diff --git a/src/BaseUriTag.php b/src/BaseUriTag.php index aff121aa..81a54b58 100644 --- a/src/BaseUriTag.php +++ b/src/BaseUriTag.php @@ -26,10 +26,10 @@ public function __construct(private readonly UriTagInterface $uriTag = new UriTa } #[Override] - public function __invoke(ResourceObject $ro): string|null + public function __invoke(ResourceObject $ro): string { if (! method_exists($ro, 'onGet')) { - return null; + return ($this->uriTag)($ro->uri); } $query = $ro->uri->query; diff --git a/src/BaseUriTagInterface.php b/src/BaseUriTagInterface.php index 944d7a79..07ccaf9d 100644 --- a/src/BaseUriTagInterface.php +++ b/src/BaseUriTagInterface.php @@ -15,6 +15,6 @@ */ interface BaseUriTagInterface { - /** Null when $ro has no onGet() (not cacheable) */ - public function __invoke(ResourceObject $ro): string|null; + /** The GET's own URI tag when $ro has no onGet() (not cacheable) */ + public function __invoke(ResourceObject $ro): string; } diff --git a/src/DonutRepository.php b/src/DonutRepository.php index c1f8178e..1ce102ce 100644 --- a/src/DonutRepository.php +++ b/src/DonutRepository.php @@ -152,7 +152,7 @@ private function doPutStatic(ResourceObject $ro, int|null $ttl, int|null $sMaxAg $this->logger->event(new PutDonutContext((string) $ro->uri, $ttl, $sMaxAge)); $keys = new SurrogateKeys($ro->uri); $keys->addTag($ro); - $this->addBaseTag($keys, $ro); + $keys->addRawTag(($this->baseUriTag)($ro)); // #255 $declaredKeys = $this->getHeaderKeys($ro); $templateKeys = $this->templateKeys($ro, $declaredKeys); $donut = ResourceDonut::create($ro, $this->renderer, $keys, $sMaxAge, true)->withStorageState($ttl, $templateKeys); @@ -179,7 +179,7 @@ private function doPutDonut(ResourceObject $ro, int|null $donutTtl): void $this->classifyMaterials($ro); $this->logger->event(new PutDonutContext((string) $ro->uri, $donutTtl, null)); $keys = new SurrogateKeys($ro->uri); - $this->addBaseTag($keys, $ro); + $keys->addRawTag(($this->baseUriTag)($ro)); // #255 $declaredKeys = $this->getHeaderKeys($ro); $templateKeys = $this->templateKeys($ro, $declaredKeys); $donut = ResourceDonut::create($ro, $this->renderer, $keys, $donutTtl, false)->withStorageState($donutTtl, $templateKeys); @@ -244,22 +244,7 @@ private function classifyMaterials(ResourceObject $ro): void */ private function templateKeys(ResourceObject $ro, array $declaredKeys): array { - $baseTag = ($this->baseUriTag)($ro); - $baseTags = $baseTag === null ? [] : [$baseTag]; - - return array_values(array_unique([($this->uriTag)($ro->uri), ...$baseTags, ...$declaredKeys])); - } - - /** - * #255: a coarser tag (see BaseUriTag) on the same SurrogateKeys instance that feeds both - * the Surrogate-Key header templateKeys() mirrors for roPool/etagPool storage. - */ - private function addBaseTag(SurrogateKeys $keys, ResourceObject $ro): void - { - $baseTag = ($this->baseUriTag)($ro); - if ($baseTag !== null) { - $keys->addRawTag($baseTag); - } + return array_values(array_unique([($this->uriTag)($ro->uri), ($this->baseUriTag)($ro), ...$declaredKeys])); } /** diff --git a/src/QueryRepository.php b/src/QueryRepository.php index 928a8724..c8c7660b 100644 --- a/src/QueryRepository.php +++ b/src/QueryRepository.php @@ -107,10 +107,7 @@ private function doPut(ResourceObject $ro): bool // #255: the etagPool's validator is scoped by saveEtag()'s own tag list, which // ResourceStorage::getTags() (the roPool's body entry) never feeds - without this, // a bare-URI purge invalidates the body but leaves a stale If-None-Match validator. - $baseTag = ($this->baseUriTag)($ro); - if ($baseTag !== null) { - $surrogateKeys = trim($surrogateKeys . ' ' . $baseTag); - } + $surrogateKeys = trim($surrogateKeys . ' ' . ($this->baseUriTag)($ro)); $this->storage->saveEtag($ro->uri, $etag, $surrogateKeys, $ttl); } diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php index 0b6154da..20853671 100644 --- a/src/ResourceStorage.php +++ b/src/ResourceStorage.php @@ -342,10 +342,8 @@ private function getTags(ResourceObject $ro, array|null $surrogateKeys = null): // #255: the base tag drops onGet()'s default-valued/unset query params, matching what // #[Purge]/#[Refresh] and MatchQuery-based auto-purge target, so a query-variant entry // (?page=2) is reachable by a purge aimed at the bare URI. - $baseTag = ($this->baseUriTag)($ro); - $baseTags = $baseTag === null ? [] : [$baseTag]; /** @var list $uniqueTags */ - $uniqueTags = array_values(array_unique([($this->uriTag)($ro->uri), ...$baseTags, ...$surrogateKeys])); + $uniqueTags = array_values(array_unique([($this->uriTag)($ro->uri), ($this->baseUriTag)($ro), ...$surrogateKeys])); return $uniqueTags; } From 09f8358d2039be919207f22614d060bde24380e9 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 10:02:32 +0900 Subject: [PATCH 03/39] Mark BaseUriTagInterface/BaseUriTag @internal; shrink SurrogateKeys API Per design review (two independent external models, Kimi K3 and K3.1 Pro, converged on this independently): which query params get dropped when coarsening a cache tag is a correctness detail that must agree with #[Purge]/MatchQuery, not a tunable policy. UriTagInterface already is the public, DI-overridable customization point for tag format - keep BaseUriTagInterface/BaseUriTag's DI wiring (still injectable for tests) but mark both @internal, with no API-stability promise, and drop BaseUriTagInterface from QueryRepositoryModule's public bindings list so it isn't advertised as a supported override point while being @internal. Also: SurrogateKeys::addRawTag() (a new public method introduced in the first #255 commit) is replaced by an optional constructor parameter (string|null $baseTag = null), matching this codebase's existing pattern (e.g. UriTagInterface $uriTag = new UriTag()) instead of adding a new verb to an existing public class's API. All prior callers (SurrogateKeysTest, ResourceDonutTest, DonutRequestTest, ResourceStorageTest) already call the single-argument form and are unaffected. Verified: composer test (489, same baseline failures/errors), cs (79/79), phpstan (36, byte-identical to baseline), psalm (5, byte-identical to baseline), QueryVariantPurgeTest (5/5, 18 assertions). --- src/BaseUriTag.php | 2 ++ src/BaseUriTagInterface.php | 3 +++ src/DonutRepository.php | 6 ++---- src/QueryRepositoryModule.php | 1 - src/SurrogateKeys.php | 10 ++-------- 5 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/BaseUriTag.php b/src/BaseUriTag.php index 81a54b58..a1752b8a 100644 --- a/src/BaseUriTag.php +++ b/src/BaseUriTag.php @@ -13,6 +13,8 @@ /** * {@inheritDoc} * + * @internal see BaseUriTagInterface + * * Mirrors MatchQuery's view of onGet(): a parameter with a default is dropped from the base * query regardless of whether the request supplied it, and a parameter onGet() does not declare * is dropped too. `onGet(int $page = 1)` turns `?page=2` into the same base tag as no query at diff --git a/src/BaseUriTagInterface.php b/src/BaseUriTagInterface.php index 07ccaf9d..e8ff0f57 100644 --- a/src/BaseUriTagInterface.php +++ b/src/BaseUriTagInterface.php @@ -11,6 +11,9 @@ * MatchQuery-based auto-purge) emit for a GET resource, so a query-variant cache entry carries a * tag that those purges can reach in addition to its own full-query tag. * + * @internal not a supported customization point - which query params a purge coarsens away is a + * correctness detail that must agree with #[Purge]/MatchQuery, not a tunable policy. Customize + * tag format/namespace via UriTagInterface instead. * @see https://github.com/bearsunday/BEAR.QueryRepository/issues/255 */ interface BaseUriTagInterface diff --git a/src/DonutRepository.php b/src/DonutRepository.php index 1ce102ce..27d675e0 100644 --- a/src/DonutRepository.php +++ b/src/DonutRepository.php @@ -150,9 +150,8 @@ private function doPutStatic(ResourceObject $ro, int|null $ttl, int|null $sMaxAg { $this->classifyMaterials($ro); $this->logger->event(new PutDonutContext((string) $ro->uri, $ttl, $sMaxAge)); - $keys = new SurrogateKeys($ro->uri); + $keys = new SurrogateKeys($ro->uri, ($this->baseUriTag)($ro)); // #255 $keys->addTag($ro); - $keys->addRawTag(($this->baseUriTag)($ro)); // #255 $declaredKeys = $this->getHeaderKeys($ro); $templateKeys = $this->templateKeys($ro, $declaredKeys); $donut = ResourceDonut::create($ro, $this->renderer, $keys, $sMaxAge, true)->withStorageState($ttl, $templateKeys); @@ -178,8 +177,7 @@ private function doPutDonut(ResourceObject $ro, int|null $donutTtl): void { $this->classifyMaterials($ro); $this->logger->event(new PutDonutContext((string) $ro->uri, $donutTtl, null)); - $keys = new SurrogateKeys($ro->uri); - $keys->addRawTag(($this->baseUriTag)($ro)); // #255 + $keys = new SurrogateKeys($ro->uri, ($this->baseUriTag)($ro)); // #255 $declaredKeys = $this->getHeaderKeys($ro); $templateKeys = $this->templateKeys($ro, $declaredKeys); $donut = ResourceDonut::create($ro, $this->renderer, $keys, $donutTtl, false)->withStorageState($donutTtl, $templateKeys); diff --git a/src/QueryRepositoryModule.php b/src/QueryRepositoryModule.php index 7db5658d..2cc583c8 100644 --- a/src/QueryRepositoryModule.php +++ b/src/QueryRepositoryModule.php @@ -34,7 +34,6 @@ * SemanticLoggerInterface (null by default - install Dev/ProdQueryRepositoryLogModule to record) * MatchQueryInterface * UriTag - * BaseUriTagInterface * DeclaredCacheControl * * The following module are installed. diff --git a/src/SurrogateKeys.php b/src/SurrogateKeys.php index 57e29beb..9b1a52bc 100644 --- a/src/SurrogateKeys.php +++ b/src/SurrogateKeys.php @@ -19,16 +19,10 @@ final class SurrogateKeys private array $surrogateKeys; private readonly UriTagInterface $uriTag; - public function __construct(AbstractUri $uri) + public function __construct(AbstractUri $uri, string|null $baseTag = null) { $this->uriTag = new UriTag(); - $this->surrogateKeys = [($this->uriTag)($uri)]; - } - - /** #255: inject a precomputed tag (BaseUriTag's output) directly, bypassing UriTag. */ - public function addRawTag(string $tag): void - { - $this->surrogateKeys[] = $tag; + $this->surrogateKeys = $baseTag === null ? [($this->uriTag)($uri)] : [($this->uriTag)($uri), $baseTag]; } /** From 751b4a6be62b2aee7b3e8faa55370df8512a273a Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 10:04:38 +0900 Subject: [PATCH 04/39] Align @internal placement with MatchQueryInterface/MatchQuery precedent The interface stays public and listed in QueryRepositoryModule's binding docblock (like MatchQueryInterface); only the concrete class carries a bare `@internal` (like MatchQuery's `/** @internal */`, with no inline justification - the "why" belongs in the class summary, not stacked onto the tag). Also fixes a factually wrong doc comment: BaseUriTag does NOT mirror MatchQuery's view of onGet() - the two take opposite positions on a default-valued parameter the request supplied (MatchQuery keeps it, BaseUriTag drops it), by design, since they serve different purposes (exact-variant re-fetch vs. coarsest-common-denominator tag). --- src/BaseUriTag.php | 12 ++++++------ src/BaseUriTagInterface.php | 3 --- src/QueryRepositoryModule.php | 1 + 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/BaseUriTag.php b/src/BaseUriTag.php index a1752b8a..edfe61c7 100644 --- a/src/BaseUriTag.php +++ b/src/BaseUriTag.php @@ -13,13 +13,13 @@ /** * {@inheritDoc} * - * @internal see BaseUriTagInterface + * @internal * - * Mirrors MatchQuery's view of onGet(): a parameter with a default is dropped from the base - * query regardless of whether the request supplied it, and a parameter onGet() does not declare - * is dropped too. `onGet(int $page = 1)` turns `?page=2` into the same base tag as no query at - * all; `onGet(int $userId, int $page = 1)` keeps $userId, so users stay isolated while only the - * page variants collapse. + * Unlike MatchQuery (which keeps a parameter whenever the request supplied it, default or not), + * this drops every default-valued onGet() parameter regardless of whether it was supplied, so + * `onGet(int $page = 1)` turns `?page=2` into the same base tag as no query at all; + * `onGet(int $userId, int $page = 1)` keeps $userId, so users stay isolated while only the page + * variants collapse. */ final class BaseUriTag implements BaseUriTagInterface { diff --git a/src/BaseUriTagInterface.php b/src/BaseUriTagInterface.php index e8ff0f57..07ccaf9d 100644 --- a/src/BaseUriTagInterface.php +++ b/src/BaseUriTagInterface.php @@ -11,9 +11,6 @@ * MatchQuery-based auto-purge) emit for a GET resource, so a query-variant cache entry carries a * tag that those purges can reach in addition to its own full-query tag. * - * @internal not a supported customization point - which query params a purge coarsens away is a - * correctness detail that must agree with #[Purge]/MatchQuery, not a tunable policy. Customize - * tag format/namespace via UriTagInterface instead. * @see https://github.com/bearsunday/BEAR.QueryRepository/issues/255 */ interface BaseUriTagInterface diff --git a/src/QueryRepositoryModule.php b/src/QueryRepositoryModule.php index 2cc583c8..7db5658d 100644 --- a/src/QueryRepositoryModule.php +++ b/src/QueryRepositoryModule.php @@ -34,6 +34,7 @@ * SemanticLoggerInterface (null by default - install Dev/ProdQueryRepositoryLogModule to record) * MatchQueryInterface * UriTag + * BaseUriTagInterface * DeclaredCacheControl * * The following module are installed. From b0cae9c1364b0f89b79a99e21d4e93b030132dae Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 10:16:26 +0900 Subject: [PATCH 05/39] Address CodeRabbit review: auto-purge coarsening, consistent default tagger, serialization All five actionable findings verified against current code and fixed (or confirmed already-fixed): 1. (Major) RefreshSameCommand/DonutCommandInterceptor's MatchQuery-driven auto-purge kept a default-valued param whenever the WRITE's own URI carried it (e.g. a PUT/POST to `?page=2`), purging only that one variant's full-query tag - never the shared base tag every variant's cache entry also carries. A write whose own query happens to include an optional onGet() param therefore left every other variant stale, even with the #255 fix in place. Both command classes now purge with a coarsened query (same drop-if-default rule as BaseUriTag) while still refreshing with the exact MatchQuery match. Reproduced before the fix (confirmed via throwaway script) and covered now by two new permanent tests - one through #[CacheableResponse]'s DonutCommandInterceptor (PagedList, reused), one through plain #[Cacheable]'s RefreshSameCommand (new PagedValue fixture, since every existing #[Cacheable] write fixture already carried an explicit #[Purge] that masked this gap). 2. (Already fixed, confirmed) DonutRepository's class complexity - addressed in commit 1fe3559, before this review ran. 3-4. (Minor) ResourceStorage and QueryRepository each built their default BaseUriTag from a fresh `new UriTag()` instead of the injected UriTagInterface. A direct (non-DI) caller supplying a custom UriTagInterface but omitting the new baseUriTag argument got a base tag in the wrong format: getTags()/doPut() would store under one format while deleteEtag()/invalidateTags() targets another, so a bare-URI purge missed those entries. Both now derive the default from the injected uriTag, matching DonutRepository's existing pattern. 5. (Minor) ResourceStorage::__serialize() omitted baseUriTag; __unserialize() unconditionally rebuilt the stock BaseUriTag, discarding any custom BaseUriTagInterface a caller had injected. Now round-tripped through the payload, with a fallback (built from the restored uriTag) for pre-existing serialized payloads that lack the key. Verified: composer test (491, baseline failures/errors unchanged), cs (79/79), phpstan (36, byte-identical to baseline after marking Props.baseUriTag optional so the fallback `??` isn't flagged redundant), psalm (5, byte-identical to baseline), phpmd (clean). Reverting RefreshSameCommand/DonutCommandInterceptor's coarsening turns the 2 new tests red; restoring turns them green. --- src/DonutCommandInterceptor.php | 26 ++++++++++++++- src/QueryRepository.php | 8 ++++- src/RefreshSameCommand.php | 26 ++++++++++++++- src/ResourceStorage.php | 14 ++++++-- .../fake-app/src/Resource/App/PagedValue.php | 32 ++++++++++++++++++ tests/QueryVariantPurgeTest.php | 33 +++++++++++++++++++ 6 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 tests/Fake/fake-app/src/Resource/App/PagedValue.php diff --git a/src/DonutCommandInterceptor.php b/src/DonutCommandInterceptor.php index bc06f97a..87947eb4 100644 --- a/src/DonutCommandInterceptor.php +++ b/src/DonutCommandInterceptor.php @@ -16,6 +16,7 @@ use Override; use Ray\Aop\MethodInterceptor; use Ray\Aop\MethodInvocation; +use ReflectionMethod; use function assert; use function call_user_func_array; @@ -80,12 +81,35 @@ public function refreshDonutAndState(ResourceObject $ro): void { $getQuery = ($this->matchQuery)($ro); $delUri = clone $ro->uri; - $delUri->query = $getQuery; + // #255: default-valued params dropped regardless of presence, matching BaseUriTag's + // rule - so this purge reaches the shared base tag every query variant's cache entry + // carries, not just the one variant $getQuery happened to match. + $delUri->query = $this->baseQuery($ro, $getQuery); $this->repository->purge($delUri); $this->refresh($getQuery, $ro); } + /** + * @param array $getQuery + * + * @return array + */ + private function baseQuery(ResourceObject $ro, array $getQuery): array + { + $baseQuery = []; + foreach ((new ReflectionMethod($ro, 'onGet'))->getParameters() as $parameter) { + if ($parameter->isDefaultValueAvailable() || ! isset($getQuery[$parameter->name])) { + continue; + } + + /** @psalm-suppress MixedAssignment */ + $baseQuery[$parameter->name] = $getQuery[$parameter->name]; + } + + return $baseQuery; + } + /** @param array $getQuery */ private function refresh(array $getQuery, ResourceObject $ro): void { diff --git a/src/QueryRepository.php b/src/QueryRepository.php index c8c7660b..4453e187 100644 --- a/src/QueryRepository.php +++ b/src/QueryRepository.php @@ -34,6 +34,8 @@ /** @internal */ final readonly class QueryRepository implements QueryRepositoryInterface { + private BaseUriTagInterface $baseUriTag; + public function __construct( #[CacheLog] private SemanticLoggerInterface $logger, @@ -42,8 +44,12 @@ public function __construct( private Expiry $expiry, private CacheDependencyInterface $cacheDependency, private DeclaredCacheControl $declaredCacheControl = new DeclaredCacheControl(), - private BaseUriTagInterface $baseUriTag = new BaseUriTag(), + private UriTagInterface $uriTag = new UriTag(), + BaseUriTagInterface|null $baseUriTag = null, ) { + // Same UriTag as $uriTag, so a custom binding's tag format stays consistent with what + // ResourceStorage::getTags() stores and deleteEtag()/invalidateTags() target. + $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); } /** diff --git a/src/RefreshSameCommand.php b/src/RefreshSameCommand.php index cacfaa61..179b9eb3 100644 --- a/src/RefreshSameCommand.php +++ b/src/RefreshSameCommand.php @@ -14,6 +14,7 @@ use Override; use Ray\Aop\MethodInvocation; use ReflectionException; +use ReflectionMethod; use function call_user_func_array; use function is_callable; @@ -57,7 +58,10 @@ public function command(MethodInvocation $invocation, ResourceObject $ro): void } $delUri = clone $ro->uri; - $delUri->query = $getQuery; + // #255: default-valued params dropped regardless of presence, matching BaseUriTag's + // rule - so this purge reaches the shared base tag every query variant's cache entry + // carries, not just the one variant $getQuery happened to match. + $delUri->query = $this->baseQuery($ro, $getQuery); // delete data in repository $this->repository->purge($delUri); @@ -72,6 +76,26 @@ public function command(MethodInvocation $invocation, ResourceObject $ro): void } } + /** + * @param array $getQuery + * + * @return array + */ + private function baseQuery(ResourceObject $ro, array $getQuery): array + { + $baseQuery = []; + foreach ((new ReflectionMethod($ro, 'onGet'))->getParameters() as $parameter) { + if ($parameter->isDefaultValueAvailable() || ! isset($getQuery[$parameter->name])) { + continue; + } + + /** @psalm-suppress MixedAssignment */ + $baseQuery[$parameter->name] = $getQuery[$parameter->name]; + } + + return $baseQuery; + } + /** * @return array * diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php index 20853671..28295cfa 100644 --- a/src/ResourceStorage.php +++ b/src/ResourceStorage.php @@ -46,7 +46,8 @@ * saver: ResourceStorageSaver, * roProvider:ProviderInterface, * etagProvider: ProviderInterface, - * serverContext: ServerContextInterface + * serverContext: ServerContextInterface, + * baseUriTag?: BaseUriTagInterface, * } * @internal */ @@ -75,6 +76,7 @@ final class ResourceStorage implements ResourceStorageInterface, ScopedValidator private ProviderInterface $etagPoolProvider; private TagAwareAdapterInterface $roPool; private TagAwareAdapterInterface $etagPool; + private BaseUriTagInterface $baseUriTag; /** * @param ProviderInterface $roPoolProvider @@ -92,8 +94,11 @@ public function __construct( #[Set(TagAwareAdapterInterface::class, EtagPool::class)] ProviderInterface $etagPoolProvider, private ResourceBodyEvaluator $evaluateBody = new ResourceBodyEvaluator(), - private BaseUriTagInterface $baseUriTag = new BaseUriTag(), + BaseUriTagInterface|null $baseUriTag = null, ) { + // Same UriTag as $this->uriTag, so a custom binding's tag format stays consistent + // between what getTags() stores and what deleteEtag()/invalidateTags() target. + $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); $this->initializePools($roPoolProvider, $etagPoolProvider); } @@ -427,6 +432,7 @@ public function __serialize(): array 'roProvider' => $this->roPoolProvider, 'etagProvider' => $this->etagPoolProvider, 'serverContext' => $this->serverContext, + 'baseUriTag' => $this->baseUriTag, ]; } @@ -445,7 +451,9 @@ public function __unserialize(array $data): void // Stateless, so rebuilt rather than carried: left out of the payload it stays // uninitialized, and the first save after a compiled-injector restore throws. $this->evaluateBody = new ResourceBodyEvaluator(); - $this->baseUriTag = new BaseUriTag($this->uriTag); + // An injected non-default implementation round-trips via the payload; a payload from + // before this key existed falls back to the default built from the restored uriTag. + $this->baseUriTag = $data['baseUriTag'] ?? new BaseUriTag($this->uriTag); $this->initializePools($data['roProvider'], $data['etagProvider']); } } diff --git a/tests/Fake/fake-app/src/Resource/App/PagedValue.php b/tests/Fake/fake-app/src/Resource/App/PagedValue.php new file mode 100644 index 00000000..9fdb1b94 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/App/PagedValue.php @@ -0,0 +1,32 @@ +body = ['page' => $page, 'time' => \microtime(true)]; + + return $this; + } + + public function onPut() + { + return $this; + } +} diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index 9cb50395..d4ad5de0 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -112,6 +112,39 @@ public function testDonutAutoPurgeReachesDefaultValuedQueryVariant(): void $this->assertNotSame($page2, (string) $this->resource->get('page://self/paged-list?page=2')); } + /** PagedList::onPost has no #[Purge]/#[Refresh]; DonutCommandInterceptor's MatchQuery-driven + * auto-purge is the only invalidation path. Posting straight to `?page=2` (the write's own + * URI carries the defaulted param) must still invalidate every page variant, not only the + * one it matched (#255 / CodeRabbit). */ + public function testDonutAutoPurgeFromWriteUriCarryingDefaultedParamReachesOtherVariants(): void + { + $bare = (string) $this->resource->get('page://self/paged-list'); + $page1 = (string) $this->resource->get('page://self/paged-list?page=1'); + $this->assertSame($bare, (string) $this->resource->get('page://self/paged-list'), 'precondition: warm cache'); + $this->assertSame($page1, (string) $this->resource->get('page://self/paged-list?page=1'), 'precondition: warm cache'); + + $this->resource->post('page://self/paged-list?page=2', ['title' => 'new']); + + $this->assertNotSame($bare, (string) $this->resource->get('page://self/paged-list')); + $this->assertNotSame($page1, (string) $this->resource->get('page://self/paged-list?page=1')); + } + + /** PagedValue::onPut has no #[Purge]/#[Refresh]; RefreshSameCommand's MatchQuery-driven + * auto-purge is the only invalidation path. Same scenario as above for a plain + * #[Cacheable] value resource (not a donut page). */ + public function testValueAutoPurgeFromWriteUriCarryingDefaultedParamReachesOtherVariants(): void + { + $bare = $this->time('app://self/paged-value'); + $page1 = $this->time('app://self/paged-value?page=1'); + $this->assertSame($bare, $this->time('app://self/paged-value'), 'precondition: warm cache'); + $this->assertSame($page1, $this->time('app://self/paged-value?page=1'), 'precondition: warm cache'); + + $this->resource->put('app://self/paged-value?page=2'); + + $this->assertNotSame($bare, $this->time('app://self/paged-value')); + $this->assertNotSame($page1, $this->time('app://self/paged-value?page=1')); + } + /** The etagPool computes its own tag list (ResourceStorage::saveEtag), independent of * getTags(); a bare purge must invalidate its If-None-Match validator too, or a * conditional GET keeps returning 304 for a body the roPool already dropped. */ From d813e72b00ca4a4059ffde328eb83399f92a44f0 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 10:19:58 +0900 Subject: [PATCH 06/39] Close patch-coverage gap: cover baseQuery's required-param branch PagedList/PagedValue each have only one (defaulted) onGet() param, so DonutCommandInterceptor/RefreshSameCommand's new baseQuery() only ever exercised its drop branch there - the keep-a-required-param branch went untested for the donut path (codecov flagged it; RefreshSameCommand's twin was already covered incidentally via OptionalParam's existing #[Purge]-driven PUT elsewhere). New fixture PagedResponseWithId has both a required `id` and a defaulted `page`, with no #[Purge]/#[Refresh] on its write, to exercise pure DonutCommandInterceptor auto-purge end-to-end: a write to `?id=1&page=2` drops `page` (coarsens, reaching `?id=1`'s bare entry) while keeping `id` (isolates - `?id=2&page=2` must survive). --- .../src/Resource/Page/PagedResponseWithId.php | 31 +++++++++++++++++++ tests/QueryVariantPurgeTest.php | 18 +++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/Fake/fake-app/src/Resource/Page/PagedResponseWithId.php diff --git a/tests/Fake/fake-app/src/Resource/Page/PagedResponseWithId.php b/tests/Fake/fake-app/src/Resource/Page/PagedResponseWithId.php new file mode 100644 index 00000000..687598f1 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/PagedResponseWithId.php @@ -0,0 +1,31 @@ +body = ['id' => $id, 'page' => $page, 'time' => \microtime(true)]; + + return $this; + } + + public function onPut(int $id) + { + return $this; + } +} diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index d4ad5de0..3c61a102 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -145,6 +145,24 @@ public function testValueAutoPurgeFromWriteUriCarryingDefaultedParamReachesOther $this->assertNotSame($page1, $this->time('app://self/paged-value?page=1')); } + /** PagedResponseWithId::onPut has no #[Purge]/#[Refresh]. The write's own URI carries both + * the required `id` and the defaulted `page`: auto-purge must drop `page` (coarsen) while + * keeping `id` (isolate) - a different id's cache must survive untouched. */ + public function testDonutAutoPurgeKeepsRequiredParamWhileDroppingDefaultedOne(): void + { + $id1Bare = (string) $this->resource->get('page://self/paged-response-with-id?id=1'); + $id1Page2 = (string) $this->resource->get('page://self/paged-response-with-id?id=1&page=2'); + $id2Page2 = (string) $this->resource->get('page://self/paged-response-with-id?id=2&page=2'); + $this->assertSame($id1Bare, (string) $this->resource->get('page://self/paged-response-with-id?id=1'), 'precondition: warm cache'); + $this->assertSame($id1Page2, (string) $this->resource->get('page://self/paged-response-with-id?id=1&page=2'), 'precondition: warm cache'); + + $this->resource->put('page://self/paged-response-with-id?id=1&page=2'); + + $this->assertNotSame($id1Bare, (string) $this->resource->get('page://self/paged-response-with-id?id=1')); + $this->assertNotSame($id1Page2, (string) $this->resource->get('page://self/paged-response-with-id?id=1&page=2')); + $this->assertSame($id2Page2, (string) $this->resource->get('page://self/paged-response-with-id?id=2&page=2'), 'a different id must not be purged'); + } + /** The etagPool computes its own tag list (ResourceStorage::saveEtag), independent of * getTags(); a bare purge must invalidate its If-None-Match validator too, or a * conditional GET keeps returning 304 for a body the roPool already dropped. */ From f3a123683eabeb9aa7c4651fb0b6c872f07de7fa Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 10:26:03 +0900 Subject: [PATCH 07/39] Document the auto-purge coarsening behavior change in CHANGELOG The #255 entry described only the tag side; RefreshSameCommand/ DonutCommandInterceptor's purge-side behavior also changed (a write carrying a defaulted param in its own URI now still invalidates every variant, not only the one it matched). PR body updated to match. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30496b85..8ec0dad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with the coarser tag that `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body, the etagPool's If-None-Match validator, and the CDN `Surrogate-Key` header alike (#255). Trade-off: every query variant now shares that coarser tag, so a write purges all of them together - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. +- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with the coarser tag that `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body, the etagPool's If-None-Match validator, and the CDN `Surrogate-Key` header alike (#255). `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only the one it matched; the subsequent refresh still re-fetches the exact variant that was written. Trade-off: every query variant now shares that coarser tag, so a write purges all of them together - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. ## [1.18.0] - 2026-10-29 From fe600dd706f159d66b6ff7565e8a1ccf53d35085 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 10:28:44 +0900 Subject: [PATCH 08/39] Trim diary-style comments to plain statements of fact Comments were narrating development history ("#255: ... without this, X happens") instead of stating the invariant a reader needs. Each either restated BaseUriTag's own documented rule (now just points at it) or was a bare issue-number tag adding nothing (removed). --- src/DonutCommandInterceptor.php | 5 ++--- src/DonutRepository.php | 4 ++-- src/QueryRepository.php | 5 ++--- src/RefreshSameCommand.php | 5 ++--- src/ResourceStorage.php | 4 +--- 5 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/DonutCommandInterceptor.php b/src/DonutCommandInterceptor.php index 87947eb4..511712de 100644 --- a/src/DonutCommandInterceptor.php +++ b/src/DonutCommandInterceptor.php @@ -81,9 +81,8 @@ public function refreshDonutAndState(ResourceObject $ro): void { $getQuery = ($this->matchQuery)($ro); $delUri = clone $ro->uri; - // #255: default-valued params dropped regardless of presence, matching BaseUriTag's - // rule - so this purge reaches the shared base tag every query variant's cache entry - // carries, not just the one variant $getQuery happened to match. + // Same coarsening rule as BaseUriTag, so this reaches every variant's shared tag, + // not just the one $getQuery matched. $delUri->query = $this->baseQuery($ro, $getQuery); $this->repository->purge($delUri); diff --git a/src/DonutRepository.php b/src/DonutRepository.php index 27d675e0..0e25445e 100644 --- a/src/DonutRepository.php +++ b/src/DonutRepository.php @@ -150,7 +150,7 @@ private function doPutStatic(ResourceObject $ro, int|null $ttl, int|null $sMaxAg { $this->classifyMaterials($ro); $this->logger->event(new PutDonutContext((string) $ro->uri, $ttl, $sMaxAge)); - $keys = new SurrogateKeys($ro->uri, ($this->baseUriTag)($ro)); // #255 + $keys = new SurrogateKeys($ro->uri, ($this->baseUriTag)($ro)); $keys->addTag($ro); $declaredKeys = $this->getHeaderKeys($ro); $templateKeys = $this->templateKeys($ro, $declaredKeys); @@ -177,7 +177,7 @@ private function doPutDonut(ResourceObject $ro, int|null $donutTtl): void { $this->classifyMaterials($ro); $this->logger->event(new PutDonutContext((string) $ro->uri, $donutTtl, null)); - $keys = new SurrogateKeys($ro->uri, ($this->baseUriTag)($ro)); // #255 + $keys = new SurrogateKeys($ro->uri, ($this->baseUriTag)($ro)); $declaredKeys = $this->getHeaderKeys($ro); $templateKeys = $this->templateKeys($ro, $declaredKeys); $donut = ResourceDonut::create($ro, $this->renderer, $keys, $donutTtl, false)->withStorageState($donutTtl, $templateKeys); diff --git a/src/QueryRepository.php b/src/QueryRepository.php index 4453e187..7829e611 100644 --- a/src/QueryRepository.php +++ b/src/QueryRepository.php @@ -110,9 +110,8 @@ private function doPut(ResourceObject $ro): bool if (isset($ro->headers[Header::ETAG])) { $etag = $ro->headers[Header::ETAG]; $surrogateKeys = $ro->headers[Header::SURROGATE_KEY] ?? ''; - // #255: the etagPool's validator is scoped by saveEtag()'s own tag list, which - // ResourceStorage::getTags() (the roPool's body entry) never feeds - without this, - // a bare-URI purge invalidates the body but leaves a stale If-None-Match validator. + // saveEtag() builds its own tag list, independent of getTags() - add the base tag + // here too, or a bare-URI purge leaves a stale If-None-Match validator behind. $surrogateKeys = trim($surrogateKeys . ' ' . ($this->baseUriTag)($ro)); $this->storage->saveEtag($ro->uri, $etag, $surrogateKeys, $ttl); diff --git a/src/RefreshSameCommand.php b/src/RefreshSameCommand.php index 179b9eb3..031cb5ae 100644 --- a/src/RefreshSameCommand.php +++ b/src/RefreshSameCommand.php @@ -58,9 +58,8 @@ public function command(MethodInvocation $invocation, ResourceObject $ro): void } $delUri = clone $ro->uri; - // #255: default-valued params dropped regardless of presence, matching BaseUriTag's - // rule - so this purge reaches the shared base tag every query variant's cache entry - // carries, not just the one variant $getQuery happened to match. + // Same coarsening rule as BaseUriTag, so this reaches every variant's shared tag, + // not just the one $getQuery matched. $delUri->query = $this->baseQuery($ro, $getQuery); // delete data in repository diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php index 28295cfa..f1f951db 100644 --- a/src/ResourceStorage.php +++ b/src/ResourceStorage.php @@ -344,9 +344,7 @@ private function getTags(ResourceObject $ro, array|null $surrogateKeys = null): // as a tag produced one non-volatile tag Set per content version that, under a // volatile-* eviction policy, is never reclaimed - leaking memory without being read. $surrogateKeys ??= isset($ro->headers[Header::SURROGATE_KEY]) ? explode(' ', $ro->headers[Header::SURROGATE_KEY]) : []; - // #255: the base tag drops onGet()'s default-valued/unset query params, matching what - // #[Purge]/#[Refresh] and MatchQuery-based auto-purge target, so a query-variant entry - // (?page=2) is reachable by a purge aimed at the bare URI. + // Reachable by a purge aimed at the bare URI (see BaseUriTag). /** @var list $uniqueTags */ $uniqueTags = array_values(array_unique([($this->uriTag)($ro->uri), ($this->baseUriTag)($ro), ...$surrogateKeys])); From 5cb1b94bdb6af4164fc87a33c06b99463c58ec0c Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 10:29:19 +0900 Subject: [PATCH 09/39] Drop remaining history/reviewer framing from two comments "a payload from before this key existed" narrated when the code changed; "(#255 / CodeRabbit)" named the reviewer, not the invariant. --- src/ResourceStorage.php | 3 +-- tests/QueryVariantPurgeTest.php | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php index f1f951db..f6313fdb 100644 --- a/src/ResourceStorage.php +++ b/src/ResourceStorage.php @@ -449,8 +449,7 @@ public function __unserialize(array $data): void // Stateless, so rebuilt rather than carried: left out of the payload it stays // uninitialized, and the first save after a compiled-injector restore throws. $this->evaluateBody = new ResourceBodyEvaluator(); - // An injected non-default implementation round-trips via the payload; a payload from - // before this key existed falls back to the default built from the restored uriTag. + // Falls back to the default (built from the restored uriTag) when a payload lacks it. $this->baseUriTag = $data['baseUriTag'] ?? new BaseUriTag($this->uriTag); $this->initializePools($data['roProvider'], $data['etagProvider']); } diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index 3c61a102..b96e73ea 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -115,7 +115,7 @@ public function testDonutAutoPurgeReachesDefaultValuedQueryVariant(): void /** PagedList::onPost has no #[Purge]/#[Refresh]; DonutCommandInterceptor's MatchQuery-driven * auto-purge is the only invalidation path. Posting straight to `?page=2` (the write's own * URI carries the defaulted param) must still invalidate every page variant, not only the - * one it matched (#255 / CodeRabbit). */ + * one it matched (#255). */ public function testDonutAutoPurgeFromWriteUriCarryingDefaultedParamReachesOtherVariants(): void { $bare = (string) $this->resource->get('page://self/paged-list'); From 53613714e5aeb514a4df592f6a7510106b869b5a Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 10:51:42 +0900 Subject: [PATCH 10/39] Fix blocker: bare-URI self-cleanup was evicting every sibling variant Found by an independent external review (fable5.1): BaseUriTag's group tag for a resource with only default-valued optional params (the bare/ required-only form) is byte-identical to that same URI's own plain UriTag - by design, since that is what lets a purge aimed at the bare URI reach every variant. But cleanUpBeforeWrite() (QueryRepository, DonutRepository) invalidates by that same plain tag on every write, including the ordinary cache-miss-then-store that runs on ANY GET, not just an explicit write. So the first time anyone fetched the bare URI, its own routine self-cleanup silently evicted every sibling variant's roPool entry, donut template, and etagPool validator - far broader and more frequent than the already-disclosed "a write purges all variants" trade-off. Confirmed with a throwaway repro before fixing: warming `?page=2`, then fetching the bare URI for the first time, evicted `?page=2` - with no write or purge call anywhere in that sequence. An initial attempt (give every entry its own distinct "identity" tag, scope cleanUpBeforeWrite to only that) broke two existing tests: cleanUpBeforeWrite's plain-tag invalidation is also how a parent page embedding a child gets busted when the child is rewritten (CacheDependency merges the child's plain tag into the parent's Surrogate-Key header) - scoping self-cleanup away from the plain tag silently broke that contract too. Corrected per review: namespace the GROUP tag instead (BaseUriTag now prefixes its output), leave cleanUpBeforeWrite's plain-tag self-cleanup untouched, and make QueryRepository::purge() - the single choke point every intentional purge path (direct, #[Purge], MatchQuery-driven auto-purge, donut) already funnels through - invalidate both the plain and the namespaced group tag. RefreshAnnotatedCommandTest::testRefreshToCacheableDestinationWritesExactlyOnce asserted CDN purge calls by exact tag-string equality; purge() now sends both tags in one call, so the assertion is updated to check token membership instead of exact equality - the behavior it verifies (explicit purge + the GET's own pre-write cleanup, two CDN calls) is unchanged. Verified: reverting src/BaseUriTag.php and the purge() change turns 3 new tests (bare-cold-miss-store must not evict a sibling, roPool/ donut/etagPool) red; restoring turns them green. composer test (495, baseline-identical 112 failures/7 errors), cs (79/79), phpstan (36, byte-identical to baseline), psalm (5, byte-identical), phpmd (clean). --- src/BaseUriTag.php | 14 ++++++++-- src/QueryRepository.php | 11 ++++++-- tests/QueryVariantPurgeTest.php | 38 +++++++++++++++++++++++++++ tests/RefreshAnnotatedCommandTest.php | 4 ++- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/BaseUriTag.php b/src/BaseUriTag.php index edfe61c7..985b986b 100644 --- a/src/BaseUriTag.php +++ b/src/BaseUriTag.php @@ -23,6 +23,16 @@ */ final class BaseUriTag implements BaseUriTagInterface { + /** + * Namespaced so this never equals a resource's own plain UriTag: the bare/required-only + * form's base tag would otherwise be byte-identical to that same URI's own full-query tag, + * which cleanUpBeforeWrite() invalidates on every write - evicting every sibling variant on + * the first write to the bare form instead of just its own entry. + * + * @see QueryRepository::GROUP_TAG_PREFIX + */ + private const GROUP_TAG_PREFIX = 'grp-'; + public function __construct(private readonly UriTagInterface $uriTag = new UriTag()) { } @@ -31,7 +41,7 @@ public function __construct(private readonly UriTagInterface $uriTag = new UriTa public function __invoke(ResourceObject $ro): string { if (! method_exists($ro, 'onGet')) { - return ($this->uriTag)($ro->uri); + return self::GROUP_TAG_PREFIX . ($this->uriTag)($ro->uri); } $query = $ro->uri->query; @@ -48,6 +58,6 @@ public function __invoke(ResourceObject $ro): string $baseUri = clone $ro->uri; $baseUri->query = $baseQuery; - return ($this->uriTag)($baseUri); + return self::GROUP_TAG_PREFIX . ($this->uriTag)($baseUri); } } diff --git a/src/QueryRepository.php b/src/QueryRepository.php index 7829e611..9b176075 100644 --- a/src/QueryRepository.php +++ b/src/QueryRepository.php @@ -34,6 +34,9 @@ /** @internal */ final readonly class QueryRepository implements QueryRepositoryInterface { + /** @see BaseUriTag::GROUP_TAG_PREFIX */ + private const GROUP_TAG_PREFIX = 'grp-'; + private BaseUriTagInterface $baseUriTag; public function __construct( @@ -206,6 +209,10 @@ public function get(AbstractUri $uri): ResourceState|null #[Override] public function purge(AbstractUri $uri) { + // Also invalidates the group tag BaseUriTag attaches to every query variant's entry, so + // a purge aimed at a bare/required-only URI reaches them too, not just $uri's own entry. + $tags = [($this->uriTag)($uri), self::GROUP_TAG_PREFIX . ($this->uriTag)($uri)]; + // A top-level purge is an application-initiated (manual) cache bust: wrap it in a // manual_purge scope so it stands out from automatic invalidation. A purge nested // inside a request GET or a write command stays an ordinary purge event there. @@ -213,7 +220,7 @@ public function purge(AbstractUri $uri) $openId = $this->logger->open(new ManualPurgeContext((string) $uri)); $purged = false; try { - return $purged = $this->storage->deleteEtag($uri); + return $purged = $this->storage->invalidateTags($tags); } finally { $this->logger->close(new ManualPurgeResultContext($purged), $openId); } @@ -221,7 +228,7 @@ public function purge(AbstractUri $uri) $this->logger->event(new PurgeContext((string) $uri)); - return $this->storage->deleteEtag($uri); + return $this->storage->invalidateTags($tags); } private function getHttpCacheAnnotation(ResourceObject $ro): HttpCache|null diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index b96e73ea..96496fbe 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -163,6 +163,44 @@ public function testDonutAutoPurgeKeepsRequiredParamWhileDroppingDefaultedOne(): $this->assertSame($id2Page2, (string) $this->resource->get('page://self/paged-response-with-id?id=2&page=2'), 'a different id must not be purged'); } + /** A bare/required-only URI's own full-query tag is the same string BaseUriTag gives every + * sibling variant (that is the mechanism #255 relies on). Storing the bare entry for the + * first time (an ordinary cache miss, not a write/purge) must not evict a sibling variant + * that was already cached - only an intentional purge/write should reach other variants. */ + public function testBareColdMissStoreDoesNotEvictSiblingVariant(): void + { + $page2 = $this->time('app://self/optional-param?id=1&page=2'); + $this->assertSame($page2, $this->time('app://self/optional-param?id=1&page=2'), 'precondition: warm cache'); + + $this->time('app://self/optional-param?id=1'); // first-ever fetch of the bare URI + + $this->assertSame($page2, $this->time('app://self/optional-param?id=1&page=2')); + } + + /** Donut flavour of the above. */ + public function testBareDonutColdMissStoreDoesNotEvictSiblingVariant(): void + { + $page2 = (string) $this->resource->get('page://self/paged-list?page=2'); + $this->assertSame($page2, (string) $this->resource->get('page://self/paged-list?page=2'), 'precondition: warm cache'); + + $this->resource->get('page://self/paged-list'); // first-ever fetch of the bare URI + + $this->assertSame($page2, (string) $this->resource->get('page://self/paged-list?page=2')); + } + + /** etagPool flavour: the bare URI's first fetch must not kill a sibling's If-None-Match + * validator either. */ + public function testBareColdMissStoreDoesNotEvictSiblingVariantEtag(): void + { + assert($this->storage instanceof ScopedValidatorInterface); + $uri = new Uri('app://self/optional-param?id=1&page=2'); + $etag = $this->resource->get('app://self/optional-param?id=1&page=2')->headers['ETag']; + + $this->resource->get('app://self/optional-param?id=1'); // first-ever fetch of the bare URI + + $this->assertTrue($this->storage->hasEtagFor($etag, $uri)); + } + /** The etagPool computes its own tag list (ResourceStorage::saveEtag), independent of * getTags(); a bare purge must invalidate its If-None-Match validator too, or a * conditional GET keeps returning 304 for a body the roPool already dropped. */ diff --git a/tests/RefreshAnnotatedCommandTest.php b/tests/RefreshAnnotatedCommandTest.php index 16f69eb1..0a121430 100644 --- a/tests/RefreshAnnotatedCommandTest.php +++ b/tests/RefreshAnnotatedCommandTest.php @@ -17,6 +17,8 @@ use function array_filter; use function array_values; +use function explode; +use function in_array; class RefreshAnnotatedCommandTest extends TestCase { @@ -73,7 +75,7 @@ public function testRefreshToCacheableDestinationWritesExactlyOnce(): void $profileTagPurges = array_values(array_filter( $this->purger->tags, - static fn (string $tag): bool => $tag === '_user_profile_user_id=1', + static fn (string $tag): bool => in_array('_user_profile_user_id=1', explode(' ', $tag), true), )); $this->assertCount(2, $profileTagPurges, 'the explicit purge plus the GET\'s own pre-write cleanup'); From 552bd7df8b4182b9a20b362351f6ac2aea960a0d Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 10:54:34 +0900 Subject: [PATCH 11/39] Update DonutCacheInterceptorTest for the group tag save_donut now records testInitialRequest asserted the exact tags array saved for the donut template entry. templateKeys()/getTags() now unconditionally include BaseUriTag's namespaced group tag (needed for the bare-URI-purge fix), so the exact-match string is updated to include it. Caught by CI, not locally: this repo's local vendor/ is pinned to a stale koriym/semantic-logger 0.9.0 against the ^0.11 constraint (a pre-existing, unrelated issue), which makes this same test fail earlier (flushAndValidate finds no save_donut event at all) for a different reason - already counted in the known 112-failure local baseline, so the new group tag never reached its real assertion locally. CI installs fresh dependencies and hit it immediately. --- tests/DonutCacheInterceptorTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/DonutCacheInterceptorTest.php b/tests/DonutCacheInterceptorTest.php index b9875ff4..63ee9d01 100644 --- a/tests/DonutCacheInterceptorTest.php +++ b/tests/DonutCacheInterceptorTest.php @@ -59,11 +59,12 @@ public function testInitialRequest(): string $this->assertSame('blog-posting:1comment01', $view); // save_donut records the tags the template entry is stored under: its own URI tag (what - // purge($uri) invalidates), plus any declared Surrogate-Key. + // purge($uri) invalidates), its group tag (what a bare-URI purge also reaches), plus any + // declared Surrogate-Key. $tree = $this->flushAndValidate($this->logger); $saveDonut = self::eventContextJsonOf($tree, 'save_donut'); $this->assertNotNull($saveDonut); - $this->assertStringContainsString('"tags":["_html_blog-posting-donut_"]', $saveDonut); + $this->assertStringContainsString('"tags":["_html_blog-posting-donut_","grp-_html_blog-posting-donut_"]', $saveDonut); return $blogPosting->headers[Header::SURROGATE_KEY]; } From 2cbc6a9fad35691b5487267ade1b85be58a35242 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 10:57:17 +0900 Subject: [PATCH 12/39] Document the bare-URI self-cleanup blocker fix in the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec0dad6..9e50d1e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with the coarser tag that `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body, the etagPool's If-None-Match validator, and the CDN `Surrogate-Key` header alike (#255). `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only the one it matched; the subsequent refresh still re-fetches the exact variant that was written. Trade-off: every query variant now shares that coarser tag, so a write purges all of them together - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. +- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with the coarser tag that `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body, the etagPool's If-None-Match validator, and the CDN `Surrogate-Key` header alike (#255). `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only that one. The coarser tag is namespaced (distinct from any resource's own plain URI tag) and `purge()` invalidates both the exact URI's own tag and this namespaced group tag; an earlier revision of this change (before release) briefly used the plain tag for the group, under which the first ordinary GET of a bare/required-only URI - no write or purge involved - silently evicted every sibling variant's cache entry and ETag validator, because that GET's own routine pre-write cleanup used the same tag the group shared. ## [1.18.0] - 2026-10-29 From 6ad533b01e914bb6608688154cb966bdcbee105a Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 11:26:13 +0900 Subject: [PATCH 13/39] Fix second blocker: donut refresh dropped the group tag from the ETag validator Found by a CodeRabbit review on the pushed B1 fix: ResourceDonut::refresh() (the "template HIT, value MISS" recomposition path for a #[CacheableResponse]/ #[DonutCache] page) rebuilds its own SurrogateKeys with no group tag. saveDonutView() is unaffected - it recomputes tags independently via getTags() - but saveEtag() takes whatever tag string it is given and does not, so a refreshed page's ETag validator silently lost its group tag. A later bare-URI purge would stop reaching that refreshed validator even though it still reaches a never-refreshed sibling's. Reproduced with a throwaway script before fixing: warm a page, drop just its value/etag entry (leaving the donut template), re-fetch to take the refresh path, then invalidate by the group tag alone - the refreshed ETag stayed reachable before the fix, gone after. Fixed by restoring the entry's originally-stored tags (computed once at creation via templateKeys(), already including the group tag) onto the response before refresh()'s own SurrogateKeys merges in the freshly re-resolved embedded children's tags. New test: testDonutRefreshKeepsTheGroupTagOnTheEtagValidator. Verified red without the ResourceDonut::refresh() fix, green with it. Also: compress BaseUriTag's GROUP_TAG_PREFIX docblock to a single factual sentence (was 4 lines of before/after framing); restore the user-facing "a write purges every variant" trade-off note in CHANGELOG, scoped to the auto-purge path specifically (an earlier revision had dropped that sentence and the previous edit's phrasing implied the trade-off applied to every store, which it no longer does after the own-tag-to-namespaced-group- tag redesign). --- CHANGELOG.md | 2 +- src/BaseUriTag.php | 6 ++-- src/ResourceDonut.php | 11 ++++++ .../Resource/Page/Html/PagedStaticDonut.php | 22 ++++++++++++ tests/QueryVariantPurgeTest.php | 35 +++++++++++++++++++ 5 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 tests/Fake/fake-app/src/Resource/Page/Html/PagedStaticDonut.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e50d1e5..de317f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with the coarser tag that `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body, the etagPool's If-None-Match validator, and the CDN `Surrogate-Key` header alike (#255). `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only that one. The coarser tag is namespaced (distinct from any resource's own plain URI tag) and `purge()` invalidates both the exact URI's own tag and this namespaced group tag; an earlier revision of this change (before release) briefly used the plain tag for the group, under which the first ordinary GET of a bare/required-only URI - no write or purge involved - silently evicted every sibling variant's cache entry and ETag validator, because that GET's own routine pre-write cleanup used the same tag the group shared. +- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with a coarser group tag matching what `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body, the etagPool's If-None-Match validator, and the CDN `Surrogate-Key` header alike, including after a `#[CacheableResponse]`/`#[DonutCache]` page's state is recomposed by `ResourceDonut::refresh()` (#255). `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only that one. Trade-off: because every variant now shares the group tag, a write still purges all of them together through this auto-purge path - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. ## [1.18.0] - 2026-10-29 diff --git a/src/BaseUriTag.php b/src/BaseUriTag.php index 985b986b..ddafe47a 100644 --- a/src/BaseUriTag.php +++ b/src/BaseUriTag.php @@ -24,10 +24,8 @@ final class BaseUriTag implements BaseUriTagInterface { /** - * Namespaced so this never equals a resource's own plain UriTag: the bare/required-only - * form's base tag would otherwise be byte-identical to that same URI's own full-query tag, - * which cleanUpBeforeWrite() invalidates on every write - evicting every sibling variant on - * the first write to the bare form instead of just its own entry. + * Namespaced so this can never collide with a resource's own plain UriTag output, which + * cleanUpBeforeWrite() invalidates on every write. * * @see QueryRepository::GROUP_TAG_PREFIX */ diff --git a/src/ResourceDonut.php b/src/ResourceDonut.php index b30b0d80..1e072311 100644 --- a/src/ResourceDonut.php +++ b/src/ResourceDonut.php @@ -11,9 +11,11 @@ use Throwable; use function array_key_exists; +use function array_unique; use function assert; use function explode; use function hash; +use function implode; use function is_iterable; use function max; use function preg_replace_callback; @@ -105,6 +107,15 @@ public function refresh(ResourceInterface $resource, ResourceObject $ro): Resour $ro->code = $this->code; } + // $this->headers predates setHeaders() adding the group tag at the entry's original + // save, so restore the stored tags here too, or a refresh drops it from the ETag + // validator and the recomposed response. + $storedTags = $this->getStorageTags(); + if ($storedTags !== []) { + $existing = isset($ro->headers[Header::SURROGATE_KEY]) ? explode(' ', $ro->headers[Header::SURROGATE_KEY]) : []; + $ro->headers[Header::SURROGATE_KEY] = implode(' ', array_unique([...$existing, ...$storedTags])); + } + $etags->setSurrogateHeader($ro); return $ro; diff --git a/tests/Fake/fake-app/src/Resource/Page/Html/PagedStaticDonut.php b/tests/Fake/fake-app/src/Resource/Page/Html/PagedStaticDonut.php new file mode 100644 index 00000000..294832c3 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/Html/PagedStaticDonut.php @@ -0,0 +1,22 @@ +body = ['page' => $page, 'time' => \microtime(true)]; + + return $this; + } +} diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index 96496fbe..1e206297 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -10,10 +10,14 @@ use BEAR\Resource\Uri; use PHPUnit\Framework\TestCase; use Ray\Di\Injector; +use ReflectionMethod; +use ReflectionProperty; +use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; use function assert; use function is_array; use function is_float; +use function is_string; /** * A GET's query-variant cache entries (`?page=2`) must be reachable by a purge aimed at the @@ -216,4 +220,35 @@ public function testBarePurgeInvalidatesQueryVariantEtag(): void $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); } + + /** A #[CacheableResponse] page's own value entry can expire independently of its donut + * template; the next GET re-saves the value via ResourceDonut::refresh(), whose + * recomposition keys must still carry the group tag, or a bare-URI purge stops reaching + * a refreshed entry's ETag validator even though it still reaches a never-refreshed one. */ + public function testDonutRefreshKeepsTheGroupTagOnTheEtagValidator(): void + { + assert($this->storage instanceof ScopedValidatorInterface); + $uri = new Uri('page://self/html/paged-static-donut'); + $this->resource->get((string) $uri); // the interceptor's own putStatic() warms both pools + + // Drop just the value/etag entry, leaving the donut template behind, by reaching into + // ResourceStorage's internal pool directly - no public API removes only one of the two. + $roPool = (new ReflectionProperty($this->storage, 'roPool'))->getValue($this->storage); + assert($roPool instanceof TagAwareAdapterInterface); + $getUriKey = new ReflectionMethod($this->storage, 'getUriKey'); + $getUriKey->setAccessible(true); + $key = $getUriKey->invoke($this->storage, $uri, 'ro-'); + assert(is_string($key)); + $roPool->deleteItem($key); + $this->assertNull($this->repository->get($uri), 'precondition: value entry gone'); + $this->assertNotNull($this->storage->getDonut($uri), 'precondition: donut template still present'); + + $refreshed = $this->resource->get((string) $uri); + $etag = $refreshed->headers['ETag']; + $this->assertTrue($this->storage->hasEtagFor($etag, $uri), 'precondition: refreshed validator registered'); + + $this->storage->invalidateTags(['grp-_html_paged-static-donut_']); + + $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); + } } From ef1cba90fad61ddf1b1ac8b4932b11fee0c8dfc8 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 15:24:33 +0900 Subject: [PATCH 14/39] Close a test-coverage gap Kimi K3.1 Pro found: initial donut save's ETag tags Mutation-tested (per the final review of this PR): removing the group tag from doPutStatic()'s SurrogateKeys construction (src/DonutRepository.php:153) left all 12 existing tests green. getTags()/templateKeys() independently recompute the group tag for the roPool body and donut template, but saveEtag() does not - it only uses whatever SurrogateKeys passes in - so an initially-saved (never-refreshed) donut entry's ETag validator had no test proving it actually needs the group tag from that specific call site. New test: testDonutInitialSaveTagsTheEtagValidatorWithTheGroupTag. Verified red against the same mutation, green against the real code. Also: scope the CHANGELOG's CDN Surrogate-Key claim to the donut/page flavor specifically (per Kimi's review) - a plain #[Cacheable] value resource's own response never carries the group tag in its Surrogate-Key header, only the etagPool's local tag list does; the CDN-facing coverage applies to #[CacheableResponse]/#[DonutCache] pages. --- CHANGELOG.md | 2 +- tests/QueryVariantPurgeTest.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de317f08..d0031915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with a coarser group tag matching what `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body, the etagPool's If-None-Match validator, and the CDN `Surrogate-Key` header alike, including after a `#[CacheableResponse]`/`#[DonutCache]` page's state is recomposed by `ResourceDonut::refresh()` (#255). `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only that one. Trade-off: because every variant now shares the group tag, a write still purges all of them together through this auto-purge path - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. +- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with a coarser group tag matching what `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body and the etagPool's If-None-Match validator for both `#[Cacheable]` value resources and `#[CacheableResponse]`/`#[DonutCache]` pages, and the latter's CDN-facing `Surrogate-Key` header too, including after a page's state is recomposed by `ResourceDonut::refresh()` (#255). `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only that one. Trade-off: because every variant now shares the group tag, a write still purges all of them together through this auto-purge path - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. ## [1.18.0] - 2026-10-29 diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index 1e206297..9c121294 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -251,4 +251,21 @@ public function testDonutRefreshKeepsTheGroupTagOnTheEtagValidator(): void $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); } + + /** The donut's SurrogateKeys (not just getTags()/templateKeys()) must carry the group tag + * too: it alone builds the etagPool's $surrogateKeys string on the never-refreshed, first + * save path (doPutStatic -> setHeaders -> saveView -> saveEtag), so an initially-saved + * entry's validator needs it independently of the roPool/template paths. */ + public function testDonutInitialSaveTagsTheEtagValidatorWithTheGroupTag(): void + { + assert($this->storage instanceof ScopedValidatorInterface); + $uri = new Uri('page://self/html/paged-static-donut'); + $ro = $this->resource->get((string) $uri); + $etag = $ro->headers['ETag']; + $this->assertTrue($this->storage->hasEtagFor($etag, $uri), 'precondition: validator registered'); + + $this->storage->invalidateTags(['grp-_html_paged-static-donut_']); + + $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); + } } From f3af963ac2a1d3b4f1649aa590e5d2dd4523e20e Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 2 Nov 2026 15:30:55 +0900 Subject: [PATCH 15/39] Close the same test-coverage gap for the dynamic #[DonutCache] flavor Swept the other three save-time group-tag insertion points besides the one Kimi found (DonutRepository.php:153, doPutStatic) to check for siblings: QueryRepository.php:118 (etagPool) and ResourceStorage.php:349 (getTags) are both already caught by existing tests. DonutRepository.php:180 (doPutDonut, the dynamic #[DonutCache] flavor's own SurrogateKeys, distinct from templateKeys()'s independently-computed roPool storage tags) was not - mutating it left all 498 tests green, since doPutDonut() never saves a value/etag entry; only its CDN-facing Surrogate-Key response header carries this SurrogateKeys instance's tags. New test: testDynamicDonutCarriesTheGroupTagInItsCdnHeader, using the existing BlogPostingDonut(onGet(int $id = 0)) fixture. Verified red against the same line-180 mutation, green against the real code. --- tests/QueryVariantPurgeTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index 9c121294..8eb7779e 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -268,4 +268,14 @@ public function testDonutInitialSaveTagsTheEtagValidatorWithTheGroupTag(): void $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); } + + /** doPutDonut() (the dynamic #[DonutCache] flavor, template-only) builds its own + * SurrogateKeys for the CDN-facing Surrogate-Key response header independently of + * templateKeys()'s roPool storage tags, so it needs the group tag added separately too. */ + public function testDynamicDonutCarriesTheGroupTagInItsCdnHeader(): void + { + $ro = $this->resource->get('page://self/html/blog-posting-donut?id=5'); + + $this->assertStringContainsString('grp-_html_blog-posting-donut_', $ro->headers['Surrogate-Key']); + } } From a2b80f39f2d778d14029091783e5d21e63e5fb37 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Tue, 3 Nov 2026 08:49:24 +0900 Subject: [PATCH 16/39] Trim narrating comments to statements of fact Comments that explained why the author did something ("so this reaches every variant, not just the one $getQuery matched", "or a bare-URI purge leaves a stale validator behind") are reduced to one line saying what the code does or what must stay in sync. Test docblocks keep the invariant only. One test docblock was also factually stale: it said the bare URI's own tag is the same string BaseUriTag gives its sibling variants, which has not been true since the group tag was namespaced with `grp-`. Comments and docblocks only; no behaviour change. --- src/DonutCommandInterceptor.php | 3 +- src/DonutRepository.php | 2 +- src/QueryRepository.php | 9 ++-- src/RefreshSameCommand.php | 3 +- src/ResourceDonut.php | 4 +- src/ResourceStorage.php | 3 +- .../fake-app/src/Resource/App/PagedValue.php | 7 +-- .../Resource/Page/Html/PagedStaticDonut.php | 2 +- .../fake-app/src/Resource/Page/PagedList.php | 5 +- .../src/Resource/Page/PagedResponseWithId.php | 6 +-- tests/QueryVariantPurgeTest.php | 49 +++++++------------ 11 files changed, 30 insertions(+), 63 deletions(-) diff --git a/src/DonutCommandInterceptor.php b/src/DonutCommandInterceptor.php index 511712de..1ad61252 100644 --- a/src/DonutCommandInterceptor.php +++ b/src/DonutCommandInterceptor.php @@ -81,8 +81,7 @@ public function refreshDonutAndState(ResourceObject $ro): void { $getQuery = ($this->matchQuery)($ro); $delUri = clone $ro->uri; - // Same coarsening rule as BaseUriTag, so this reaches every variant's shared tag, - // not just the one $getQuery matched. + // Coarsened like BaseUriTag (keep the two in sync); the refresh below still uses the exact $getQuery. $delUri->query = $this->baseQuery($ro, $getQuery); $this->repository->purge($delUri); diff --git a/src/DonutRepository.php b/src/DonutRepository.php index 0e25445e..1f2b9830 100644 --- a/src/DonutRepository.php +++ b/src/DonutRepository.php @@ -57,7 +57,7 @@ public function __construct( ) { // Same UriTag as templateKeys(), so a material's tag matches the format purge() emits. $this->cacheDependency = $cacheDependency ?? new CacheDependency($this->uriTag, $this->logger); - // Same UriTag as $this->uriTag, so a custom binding's tag format stays consistent. + // Built from $this->uriTag so a custom UriTagInterface applies to the group tag too. $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); } diff --git a/src/QueryRepository.php b/src/QueryRepository.php index 9b176075..668c4a43 100644 --- a/src/QueryRepository.php +++ b/src/QueryRepository.php @@ -50,8 +50,7 @@ public function __construct( private UriTagInterface $uriTag = new UriTag(), BaseUriTagInterface|null $baseUriTag = null, ) { - // Same UriTag as $uriTag, so a custom binding's tag format stays consistent with what - // ResourceStorage::getTags() stores and deleteEtag()/invalidateTags() target. + // Built from $uriTag so a custom UriTagInterface applies to the group tag too. $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); } @@ -113,8 +112,7 @@ private function doPut(ResourceObject $ro): bool if (isset($ro->headers[Header::ETAG])) { $etag = $ro->headers[Header::ETAG]; $surrogateKeys = $ro->headers[Header::SURROGATE_KEY] ?? ''; - // saveEtag() builds its own tag list, independent of getTags() - add the base tag - // here too, or a bare-URI purge leaves a stale If-None-Match validator behind. + // saveEtag() takes its own tag list rather than getTags(), so the group tag is added here. $surrogateKeys = trim($surrogateKeys . ' ' . ($this->baseUriTag)($ro)); $this->storage->saveEtag($ro->uri, $etag, $surrogateKeys, $ttl); @@ -209,8 +207,7 @@ public function get(AbstractUri $uri): ResourceState|null #[Override] public function purge(AbstractUri $uri) { - // Also invalidates the group tag BaseUriTag attaches to every query variant's entry, so - // a purge aimed at a bare/required-only URI reaches them too, not just $uri's own entry. + // The URI's own tag plus the group tag shared by its query variants (see BaseUriTag). $tags = [($this->uriTag)($uri), self::GROUP_TAG_PREFIX . ($this->uriTag)($uri)]; // A top-level purge is an application-initiated (manual) cache bust: wrap it in a diff --git a/src/RefreshSameCommand.php b/src/RefreshSameCommand.php index 031cb5ae..61518c71 100644 --- a/src/RefreshSameCommand.php +++ b/src/RefreshSameCommand.php @@ -58,8 +58,7 @@ public function command(MethodInvocation $invocation, ResourceObject $ro): void } $delUri = clone $ro->uri; - // Same coarsening rule as BaseUriTag, so this reaches every variant's shared tag, - // not just the one $getQuery matched. + // Coarsened like BaseUriTag (keep the two in sync); the refresh below still uses the exact $getQuery. $delUri->query = $this->baseQuery($ro, $getQuery); // delete data in repository diff --git a/src/ResourceDonut.php b/src/ResourceDonut.php index 1e072311..6397aa9d 100644 --- a/src/ResourceDonut.php +++ b/src/ResourceDonut.php @@ -107,9 +107,7 @@ public function refresh(ResourceInterface $resource, ResourceObject $ro): Resour $ro->code = $this->code; } - // $this->headers predates setHeaders() adding the group tag at the entry's original - // save, so restore the stored tags here too, or a refresh drops it from the ETag - // validator and the recomposed response. + // $this->headers predates the group tag; restore the stored tags. $storedTags = $this->getStorageTags(); if ($storedTags !== []) { $existing = isset($ro->headers[Header::SURROGATE_KEY]) ? explode(' ', $ro->headers[Header::SURROGATE_KEY]) : []; diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php index f6313fdb..374138f2 100644 --- a/src/ResourceStorage.php +++ b/src/ResourceStorage.php @@ -96,8 +96,7 @@ public function __construct( private ResourceBodyEvaluator $evaluateBody = new ResourceBodyEvaluator(), BaseUriTagInterface|null $baseUriTag = null, ) { - // Same UriTag as $this->uriTag, so a custom binding's tag format stays consistent - // between what getTags() stores and what deleteEtag()/invalidateTags() target. + // Built from $this->uriTag so a custom UriTagInterface applies to the group tag too. $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); $this->initializePools($roPoolProvider, $etagPoolProvider); } diff --git a/tests/Fake/fake-app/src/Resource/App/PagedValue.php b/tests/Fake/fake-app/src/Resource/App/PagedValue.php index 9fdb1b94..69bc3cc4 100644 --- a/tests/Fake/fake-app/src/Resource/App/PagedValue.php +++ b/tests/Fake/fake-app/src/Resource/App/PagedValue.php @@ -9,12 +9,7 @@ use BEAR\RepositoryModule\Annotation\Cacheable; use BEAR\Resource\ResourceObject; -/** - * A #[Cacheable] value resource with a default-valued onGet() parameter and NO #[Purge]/ - * #[Refresh] on its write method, so RefreshSameCommand's MatchQuery-driven auto-purge is the - * only invalidation path exercised (#255: a write whose own URI carries `page` must still - * invalidate every page variant, not only the one it matched). - */ +/** A #[Cacheable] value resource with a default-valued onGet() parameter; onPut has no #[Purge]/#[Refresh]. */ #[Cacheable] class PagedValue extends ResourceObject { diff --git a/tests/Fake/fake-app/src/Resource/Page/Html/PagedStaticDonut.php b/tests/Fake/fake-app/src/Resource/Page/Html/PagedStaticDonut.php index 294832c3..ca4f2dd5 100644 --- a/tests/Fake/fake-app/src/Resource/Page/Html/PagedStaticDonut.php +++ b/tests/Fake/fake-app/src/Resource/Page/Html/PagedStaticDonut.php @@ -9,7 +9,7 @@ use BEAR\RepositoryModule\Annotation\CacheableResponse; use BEAR\Resource\ResourceObject; -/** A #[CacheableResponse] page with a default-valued onGet() param, for refresh-path repro only. */ +/** A #[CacheableResponse] page whose only onGet() parameter has a default. */ #[CacheableResponse] class PagedStaticDonut extends ResourceObject { diff --git a/tests/Fake/fake-app/src/Resource/Page/PagedList.php b/tests/Fake/fake-app/src/Resource/Page/PagedList.php index d380987c..ea28296b 100644 --- a/tests/Fake/fake-app/src/Resource/Page/PagedList.php +++ b/tests/Fake/fake-app/src/Resource/Page/PagedList.php @@ -9,10 +9,7 @@ use BEAR\RepositoryModule\Annotation\CacheableResponse; use BEAR\Resource\ResourceObject; -/** - * A #[CacheableResponse] page with a default-valued onGet() parameter (#255): onPost's - * auto-purge targets the bare URI, and ?page=2's stored view/validator must still be reachable. - */ +/** A #[CacheableResponse] page with a default-valued onGet() parameter; onPost has no #[Purge]/#[Refresh]. */ #[CacheableResponse] class PagedList extends ResourceObject { diff --git a/tests/Fake/fake-app/src/Resource/Page/PagedResponseWithId.php b/tests/Fake/fake-app/src/Resource/Page/PagedResponseWithId.php index 687598f1..0ca62b8b 100644 --- a/tests/Fake/fake-app/src/Resource/Page/PagedResponseWithId.php +++ b/tests/Fake/fake-app/src/Resource/Page/PagedResponseWithId.php @@ -9,11 +9,7 @@ use BEAR\RepositoryModule\Annotation\CacheableResponse; use BEAR\Resource\ResourceObject; -/** - * A #[CacheableResponse] page with both a required and a default-valued onGet() parameter, and - * NO #[Purge]/#[Refresh] on its write method: exercises DonutCommandInterceptor's auto-purge - * keeping the required param while dropping the defaulted one (#255). - */ +/** A #[CacheableResponse] page with a required and a default-valued onGet() parameter; onPut has no #[Purge]/#[Refresh]. */ #[CacheableResponse] class PagedResponseWithId extends ResourceObject { diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index 8eb7779e..bf2b6f2d 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -89,7 +89,7 @@ public function testDirectPurgeReachesDefaultValuedQueryVariant(): void $this->assertNotSame($page2, $this->time('app://self/optional-param?id=1&page=2')); } - /** A query onGet() does not declare at all (not merely defaulted) must drop too. */ + /** Auto-purge also reaches a query that onGet() does not declare at all. */ public function testAutoPurgeReachesQueryNotInOnGetSignature(): void { $plain = $this->time('app://self/user?id=1'); @@ -116,10 +116,8 @@ public function testDonutAutoPurgeReachesDefaultValuedQueryVariant(): void $this->assertNotSame($page2, (string) $this->resource->get('page://self/paged-list?page=2')); } - /** PagedList::onPost has no #[Purge]/#[Refresh]; DonutCommandInterceptor's MatchQuery-driven - * auto-purge is the only invalidation path. Posting straight to `?page=2` (the write's own - * URI carries the defaulted param) must still invalidate every page variant, not only the - * one it matched (#255). */ + /** PagedList::onPost has no #[Purge]/#[Refresh], so DonutCommandInterceptor's auto-purge is the only + * invalidation: a write to `?page=2` reaches every page variant. */ public function testDonutAutoPurgeFromWriteUriCarryingDefaultedParamReachesOtherVariants(): void { $bare = (string) $this->resource->get('page://self/paged-list'); @@ -133,9 +131,8 @@ public function testDonutAutoPurgeFromWriteUriCarryingDefaultedParamReachesOther $this->assertNotSame($page1, (string) $this->resource->get('page://self/paged-list?page=1')); } - /** PagedValue::onPut has no #[Purge]/#[Refresh]; RefreshSameCommand's MatchQuery-driven - * auto-purge is the only invalidation path. Same scenario as above for a plain - * #[Cacheable] value resource (not a donut page). */ + /** Same for a plain #[Cacheable] value resource: PagedValue::onPut has no #[Purge]/#[Refresh], so + * RefreshSameCommand's auto-purge is the only invalidation. */ public function testValueAutoPurgeFromWriteUriCarryingDefaultedParamReachesOtherVariants(): void { $bare = $this->time('app://self/paged-value'); @@ -149,9 +146,8 @@ public function testValueAutoPurgeFromWriteUriCarryingDefaultedParamReachesOther $this->assertNotSame($page1, $this->time('app://self/paged-value?page=1')); } - /** PagedResponseWithId::onPut has no #[Purge]/#[Refresh]. The write's own URI carries both - * the required `id` and the defaulted `page`: auto-purge must drop `page` (coarsen) while - * keeping `id` (isolate) - a different id's cache must survive untouched. */ + /** The write's URI carries the required `id` and the defaulted `page`: auto-purge drops `page` and + * keeps `id`, so another id's cache survives. */ public function testDonutAutoPurgeKeepsRequiredParamWhileDroppingDefaultedOne(): void { $id1Bare = (string) $this->resource->get('page://self/paged-response-with-id?id=1'); @@ -167,10 +163,8 @@ public function testDonutAutoPurgeKeepsRequiredParamWhileDroppingDefaultedOne(): $this->assertSame($id2Page2, (string) $this->resource->get('page://self/paged-response-with-id?id=2&page=2'), 'a different id must not be purged'); } - /** A bare/required-only URI's own full-query tag is the same string BaseUriTag gives every - * sibling variant (that is the mechanism #255 relies on). Storing the bare entry for the - * first time (an ordinary cache miss, not a write/purge) must not evict a sibling variant - * that was already cached - only an intentional purge/write should reach other variants. */ + /** Storing the bare URI on a cache miss (no write, no purge) must not evict an already-cached + * sibling variant. */ public function testBareColdMissStoreDoesNotEvictSiblingVariant(): void { $page2 = $this->time('app://self/optional-param?id=1&page=2'); @@ -192,8 +186,7 @@ public function testBareDonutColdMissStoreDoesNotEvictSiblingVariant(): void $this->assertSame($page2, (string) $this->resource->get('page://self/paged-list?page=2')); } - /** etagPool flavour: the bare URI's first fetch must not kill a sibling's If-None-Match - * validator either. */ + /** etagPool flavour: the bare URI's first fetch keeps the sibling's If-None-Match validator. */ public function testBareColdMissStoreDoesNotEvictSiblingVariantEtag(): void { assert($this->storage instanceof ScopedValidatorInterface); @@ -205,9 +198,8 @@ public function testBareColdMissStoreDoesNotEvictSiblingVariantEtag(): void $this->assertTrue($this->storage->hasEtagFor($etag, $uri)); } - /** The etagPool computes its own tag list (ResourceStorage::saveEtag), independent of - * getTags(); a bare purge must invalidate its If-None-Match validator too, or a - * conditional GET keeps returning 304 for a body the roPool already dropped. */ + /** A bare purge also invalidates the variant's If-None-Match validator; the etagPool builds its tag + * list apart from getTags(). */ public function testBarePurgeInvalidatesQueryVariantEtag(): void { assert($this->storage instanceof ScopedValidatorInterface); @@ -221,10 +213,8 @@ public function testBarePurgeInvalidatesQueryVariantEtag(): void $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); } - /** A #[CacheableResponse] page's own value entry can expire independently of its donut - * template; the next GET re-saves the value via ResourceDonut::refresh(), whose - * recomposition keys must still carry the group tag, or a bare-URI purge stops reaching - * a refreshed entry's ETag validator even though it still reaches a never-refreshed one. */ + /** After ResourceDonut::refresh() re-saves a page's value (its template survived), the ETag validator + * still carries the group tag. */ public function testDonutRefreshKeepsTheGroupTagOnTheEtagValidator(): void { assert($this->storage instanceof ScopedValidatorInterface); @@ -252,10 +242,8 @@ public function testDonutRefreshKeepsTheGroupTagOnTheEtagValidator(): void $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); } - /** The donut's SurrogateKeys (not just getTags()/templateKeys()) must carry the group tag - * too: it alone builds the etagPool's $surrogateKeys string on the never-refreshed, first - * save path (doPutStatic -> setHeaders -> saveView -> saveEtag), so an initially-saved - * entry's validator needs it independently of the roPool/template paths. */ + /** On the first, never-refreshed save, doPutStatic()'s SurrogateKeys is what tags the etagPool entry, + * so it must carry the group tag itself. */ public function testDonutInitialSaveTagsTheEtagValidatorWithTheGroupTag(): void { assert($this->storage instanceof ScopedValidatorInterface); @@ -269,9 +257,8 @@ public function testDonutInitialSaveTagsTheEtagValidatorWithTheGroupTag(): void $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); } - /** doPutDonut() (the dynamic #[DonutCache] flavor, template-only) builds its own - * SurrogateKeys for the CDN-facing Surrogate-Key response header independently of - * templateKeys()'s roPool storage tags, so it needs the group tag added separately too. */ + /** doPutDonut() (dynamic #[DonutCache], template-only) builds the CDN Surrogate-Key header from its own + * SurrogateKeys, separate from templateKeys(), so it carries the group tag itself. */ public function testDynamicDonutCarriesTheGroupTagInItsCdnHeader(): void { $ro = $this->resource->get('page://self/html/blog-posting-donut?id=5'); From 9523135d87dba35c97e1c2e7d7ed912a5178ab71 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Tue, 3 Nov 2026 08:50:22 +0900 Subject: [PATCH 17/39] State what BaseUriTag is instead of why it was added The class docblock contrasted itself with MatchQuery in five lines of prose; the interface docblock described the purpose of the change. Both now say what the tag is. The interface's return note said the bare URI's own tag was returned for a resource without onGet(), which stopped being true when the tag was namespaced with `grp-`. Comments only; no behaviour change. --- src/BaseUriTag.php | 10 +++------- src/BaseUriTagInterface.php | 7 +++---- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/BaseUriTag.php b/src/BaseUriTag.php index ddafe47a..8b0bb343 100644 --- a/src/BaseUriTag.php +++ b/src/BaseUriTag.php @@ -15,17 +15,13 @@ * * @internal * - * Unlike MatchQuery (which keeps a parameter whenever the request supplied it, default or not), - * this drops every default-valued onGet() parameter regardless of whether it was supplied, so - * `onGet(int $page = 1)` turns `?page=2` into the same base tag as no query at all; - * `onGet(int $userId, int $page = 1)` keeps $userId, so users stay isolated while only the page - * variants collapse. + * Drops every default-valued onGet() parameter, supplied or not (MatchQuery keeps the supplied ones); + * required parameters stay: `onGet(int $userId, int $page = 1)` maps `?userId=1&page=2` to `?userId=1`. */ final class BaseUriTag implements BaseUriTagInterface { /** - * Namespaced so this can never collide with a resource's own plain UriTag output, which - * cleanUpBeforeWrite() invalidates on every write. + * Keeps the group tag distinct from a resource's own UriTag, which cleanUpBeforeWrite() invalidates. * * @see QueryRepository::GROUP_TAG_PREFIX */ diff --git a/src/BaseUriTagInterface.php b/src/BaseUriTagInterface.php index 07ccaf9d..db93d4d1 100644 --- a/src/BaseUriTagInterface.php +++ b/src/BaseUriTagInterface.php @@ -7,14 +7,13 @@ use BEAR\Resource\ResourceObject; /** - * Builds the coarser surrogate tag that write-side purges (#[Purge]/#[Refresh]'s URI, and - * MatchQuery-based auto-purge) emit for a GET resource, so a query-variant cache entry carries a - * tag that those purges can reach in addition to its own full-query tag. + * The group tag of a GET resource: its URI tag without the default-valued onGet() parameters. + * A query-variant cache entry carries it besides its own tag, and a purge of the bare URI targets it. * * @see https://github.com/bearsunday/BEAR.QueryRepository/issues/255 */ interface BaseUriTagInterface { - /** The GET's own URI tag when $ro has no onGet() (not cacheable) */ + /** Without onGet(), the group tag is derived from the full URI. */ public function __invoke(ResourceObject $ro): string; } From ea7a631db04eddfcc74efbe6eae2ff84868ffdca Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Tue, 3 Nov 2026 08:51:03 +0900 Subject: [PATCH 18/39] Say what the default BaseUriTag is in the constructors The three constructor comments gave a reason ("so a custom UriTagInterface applies ..."); they now state that the default BaseUriTag shares the class's own UriTagInterface. Comments only. --- src/DonutRepository.php | 2 +- src/QueryRepository.php | 2 +- src/ResourceStorage.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/DonutRepository.php b/src/DonutRepository.php index 1f2b9830..aa6fc32d 100644 --- a/src/DonutRepository.php +++ b/src/DonutRepository.php @@ -57,7 +57,7 @@ public function __construct( ) { // Same UriTag as templateKeys(), so a material's tag matches the format purge() emits. $this->cacheDependency = $cacheDependency ?? new CacheDependency($this->uriTag, $this->logger); - // Built from $this->uriTag so a custom UriTagInterface applies to the group tag too. + // The default BaseUriTag shares this UriTagInterface. $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); } diff --git a/src/QueryRepository.php b/src/QueryRepository.php index 668c4a43..4cb9aec8 100644 --- a/src/QueryRepository.php +++ b/src/QueryRepository.php @@ -50,7 +50,7 @@ public function __construct( private UriTagInterface $uriTag = new UriTag(), BaseUriTagInterface|null $baseUriTag = null, ) { - // Built from $uriTag so a custom UriTagInterface applies to the group tag too. + // The default BaseUriTag shares this UriTagInterface. $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); } diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php index 374138f2..9ee3fa08 100644 --- a/src/ResourceStorage.php +++ b/src/ResourceStorage.php @@ -96,7 +96,7 @@ public function __construct( private ResourceBodyEvaluator $evaluateBody = new ResourceBodyEvaluator(), BaseUriTagInterface|null $baseUriTag = null, ) { - // Built from $this->uriTag so a custom UriTagInterface applies to the group tag too. + // The default BaseUriTag shares this UriTagInterface. $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); $this->initializePools($roPoolProvider, $etagPoolProvider); } From 3905aef3c465b3264ef10ecd18317628da35cdf8 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Tue, 3 Nov 2026 08:51:31 +0900 Subject: [PATCH 19/39] Shorten the saveEtag() comment to a statement of fact --- src/QueryRepository.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/QueryRepository.php b/src/QueryRepository.php index 4cb9aec8..638c559f 100644 --- a/src/QueryRepository.php +++ b/src/QueryRepository.php @@ -112,7 +112,7 @@ private function doPut(ResourceObject $ro): bool if (isset($ro->headers[Header::ETAG])) { $etag = $ro->headers[Header::ETAG]; $surrogateKeys = $ro->headers[Header::SURROGATE_KEY] ?? ''; - // saveEtag() takes its own tag list rather than getTags(), so the group tag is added here. + // saveEtag() takes a tag list of its own, not getTags()'s. $surrogateKeys = trim($surrogateKeys . ' ' . ($this->baseUriTag)($ro)); $this->storage->saveEtag($ro->uri, $etag, $surrogateKeys, $ttl); From a95fab69bc9c05a25361e001503512d117b3828e Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Tue, 3 Nov 2026 09:05:58 +0900 Subject: [PATCH 20/39] Make BaseUriTagInterface a required constructor dependency QueryRepository, DonutRepository and ResourceStorage took it as a nullable parameter resolved in the body, which kept it out of promotion and let a direct caller get a BaseUriTag built from a different UriTagInterface than the one the class itself uses. It is now a required promoted parameter; DI already binds it. Direct constructions in tests are updated, and the custom-tagger test builds its BaseUriTag from the same custom tagger. --- src/DonutRepository.php | 11 ++--------- src/QueryRepository.php | 6 +----- src/ResourceStorage.php | 5 +---- tests/DonutMaterialDependencyTest.php | 28 ++++++++++++++------------- tests/ResourceRepositoryTest.php | 4 ++++ tests/ResourceStorageTest.php | 4 ++++ 6 files changed, 27 insertions(+), 31 deletions(-) diff --git a/src/DonutRepository.php b/src/DonutRepository.php index aa6fc32d..4639a7fe 100644 --- a/src/DonutRepository.php +++ b/src/DonutRepository.php @@ -34,13 +34,8 @@ final readonly class DonutRepository implements DonutRepositoryInterface { private CacheDependencyInterface $cacheDependency; - private BaseUriTagInterface $baseUriTag; - /** - * Append new parameters only: direct (non-DI) callers pass them positionally. - * - * @SuppressWarnings("PHPMD.ExcessiveParameterList") - */ + /** @SuppressWarnings("PHPMD.ExcessiveParameterList") */ public function __construct( private QueryRepositoryInterface $queryRepository, private HeaderSetter $headerSetter, @@ -50,15 +45,13 @@ public function __construct( #[CacheLog] private SemanticLoggerInterface $logger, private DonutRendererInterface $renderer, + private BaseUriTagInterface $baseUriTag, private UriTagInterface $uriTag = new UriTag(), CacheDependencyInterface|null $cacheDependency = null, private DeclaredCacheControl $declaredCacheControl = new DeclaredCacheControl(), - BaseUriTagInterface|null $baseUriTag = null, ) { // Same UriTag as templateKeys(), so a material's tag matches the format purge() emits. $this->cacheDependency = $cacheDependency ?? new CacheDependency($this->uriTag, $this->logger); - // The default BaseUriTag shares this UriTagInterface. - $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); } #[Override] diff --git a/src/QueryRepository.php b/src/QueryRepository.php index 638c559f..de0bb9d6 100644 --- a/src/QueryRepository.php +++ b/src/QueryRepository.php @@ -37,8 +37,6 @@ /** @see BaseUriTag::GROUP_TAG_PREFIX */ private const GROUP_TAG_PREFIX = 'grp-'; - private BaseUriTagInterface $baseUriTag; - public function __construct( #[CacheLog] private SemanticLoggerInterface $logger, @@ -46,12 +44,10 @@ public function __construct( private ResourceStorageInterface $storage, private Expiry $expiry, private CacheDependencyInterface $cacheDependency, + private BaseUriTagInterface $baseUriTag, private DeclaredCacheControl $declaredCacheControl = new DeclaredCacheControl(), private UriTagInterface $uriTag = new UriTag(), - BaseUriTagInterface|null $baseUriTag = null, ) { - // The default BaseUriTag shares this UriTagInterface. - $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); } /** diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php index 9ee3fa08..51dd6657 100644 --- a/src/ResourceStorage.php +++ b/src/ResourceStorage.php @@ -76,7 +76,6 @@ final class ResourceStorage implements ResourceStorageInterface, ScopedValidator private ProviderInterface $etagPoolProvider; private TagAwareAdapterInterface $roPool; private TagAwareAdapterInterface $etagPool; - private BaseUriTagInterface $baseUriTag; /** * @param ProviderInterface $roPoolProvider @@ -93,11 +92,9 @@ public function __construct( ProviderInterface $roPoolProvider, #[Set(TagAwareAdapterInterface::class, EtagPool::class)] ProviderInterface $etagPoolProvider, + private BaseUriTagInterface $baseUriTag, private ResourceBodyEvaluator $evaluateBody = new ResourceBodyEvaluator(), - BaseUriTagInterface|null $baseUriTag = null, ) { - // The default BaseUriTag shares this UriTagInterface. - $this->baseUriTag = $baseUriTag ?? new BaseUriTag($this->uriTag); $this->initializePools($roPoolProvider, $etagPoolProvider); } diff --git a/tests/DonutMaterialDependencyTest.php b/tests/DonutMaterialDependencyTest.php index 64f75939..08010ae0 100644 --- a/tests/DonutMaterialDependencyTest.php +++ b/tests/DonutMaterialDependencyTest.php @@ -297,6 +297,19 @@ public function testAManuallyConstructedRepositoryUsesItsOwnUriTagForTheDefaultC $injector = new Injector($module, __DIR__ . '/tmp'); $resource = $injector->getInstance(ResourceInterface::class); + $uriTag = new class implements UriTagInterface { + #[Override] + public function __invoke(AbstractUri $uri): string + { + return 'customtag_' . str_replace(['/', '?', '&', ':'], '_', (string) $uri); + } + + #[Override] + public function fromAssoc(string $uriTemplate, array $assoc): string + { + return ''; + } + }; $repository = new DonutRepository( $injector->getInstance(QueryRepositoryInterface::class), $injector->getInstance(HeaderSetter::class), @@ -305,19 +318,8 @@ public function testAManuallyConstructedRepositoryUsesItsOwnUriTagForTheDefaultC $injector->getInstance(CdnCacheControlHeaderSetterInterface::class), $injector->getInstance(SemanticLoggerInterface::class, CacheLog::class), $injector->getInstance(DonutRendererInterface::class), - new class implements UriTagInterface { - #[Override] - public function __invoke(AbstractUri $uri): string - { - return 'customtag_' . str_replace(['/', '?', '&', ':'], '_', (string) $uri); - } - - #[Override] - public function fromAssoc(string $uriTemplate, array $assoc): string - { - return ''; - } - }, + new BaseUriTag($uriTag), + $uriTag, ); // uri() (not newRequest(), unavailable before bear/resource 1.2x) is the same GET diff --git a/tests/ResourceRepositoryTest.php b/tests/ResourceRepositoryTest.php index 35c44e9a..e076728d 100644 --- a/tests/ResourceRepositoryTest.php +++ b/tests/ResourceRepositoryTest.php @@ -48,9 +48,11 @@ public function get() new GlobalServerContext(), $tagAwareAdapterProvider, $tagAwareAdapterProvider, + new BaseUriTag(new UriTag()), ), new Expiry(0, 0, 0), new CacheDependency(new UriTag()), + new BaseUriTag(new UriTag()), ); $this->ro = new Index(); $this->ro->uri = new Uri('page://self/user'); @@ -130,9 +132,11 @@ public function get() new GlobalServerContext(), $tagAwareAdapterProvider, $tagAwareAdapterProvider, + new BaseUriTag(new UriTag()), ), new Expiry(0, 0, 0), new CacheDependency(new UriTag()), + new BaseUriTag(new UriTag()), ); $this->assertInstanceOf(Repository::class, $repository); } diff --git a/tests/ResourceStorageTest.php b/tests/ResourceStorageTest.php index 4b8f08e5..664f8b59 100644 --- a/tests/ResourceStorageTest.php +++ b/tests/ResourceStorageTest.php @@ -61,6 +61,7 @@ public function get() new GlobalServerContext(), $provider($roPool ?? $tagAwareAdapter), $provider($etagPool ?? $tagAwareAdapter), + new BaseUriTag(new UriTag()), ); } @@ -94,6 +95,7 @@ public function testARestoredStorageStillWrites(): void new GlobalServerContext(), new FakeArrayPoolProvider(), new FakeArrayPoolProvider(), + new BaseUriTag(new UriTag()), ); $storage = unserialize(serialize($serializable)); assert($storage instanceof ResourceStorage); @@ -246,6 +248,7 @@ public function get() new GlobalServerContext(), $poolProvider, $poolProvider, + new BaseUriTag(new UriTag()), ); $saved = $storage->saveValue($this->ro, 10); @@ -288,6 +291,7 @@ public function get() new GlobalServerContext(), $poolProvider, $poolProvider, + new BaseUriTag(new UriTag()), ); $donut = ResourceDonut::create($this->ro, new DonutRenderer(), new SurrogateKeys(new Uri('app://self/')), null, false); From 85eecb0b0a88328f2b3cbfc560cf5cfae88383fe Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Tue, 3 Nov 2026 09:12:01 +0900 Subject: [PATCH 21/39] Keep query variants cached when the bare URI answers an error CacheInterceptor drops a GET's cached entry when it answers non-200, by calling purge(). Since purge() now also invalidates the group tag, an error on the bare URI (a 503 during a backend outage, say) evicted every cached query variant too, sending all of them to the failing backend. The error path now invalidates only the URI's own tag, as before this change; the PurgeContext event it logs is unchanged. --- CHANGELOG.md | 2 +- src/CacheInterceptor.php | 7 +++++- .../src/Resource/App/PagedErrorOnBare.php | 25 +++++++++++++++++++ tests/QueryVariantPurgeTest.php | 17 +++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/Fake/fake-app/src/Resource/App/PagedErrorOnBare.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d0031915..70c4703c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with a coarser group tag matching what `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body and the etagPool's If-None-Match validator for both `#[Cacheable]` value resources and `#[CacheableResponse]`/`#[DonutCache]` pages, and the latter's CDN-facing `Surrogate-Key` header too, including after a page's state is recomposed by `ResourceDonut::refresh()` (#255). `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only that one. Trade-off: because every variant now shares the group tag, a write still purges all of them together through this auto-purge path - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. +- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with a coarser group tag matching what `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body and the etagPool's If-None-Match validator for both `#[Cacheable]` value resources and `#[CacheableResponse]`/`#[DonutCache]` pages, and the latter's CDN-facing `Surrogate-Key` header too, including after a page's state is recomposed by `ResourceDonut::refresh()` (#255). `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only that one. Trade-off: because every variant now shares the group tag, a write still purges all of them together through this auto-purge path - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. ## [1.18.0] - 2026-10-29 diff --git a/src/CacheInterceptor.php b/src/CacheInterceptor.php index 72610bcc..82305a03 100644 --- a/src/CacheInterceptor.php +++ b/src/CacheInterceptor.php @@ -9,6 +9,7 @@ use BEAR\QueryRepository\Log\Context\CacheHitContext; use BEAR\QueryRepository\Log\Context\CacheMissContext; use BEAR\QueryRepository\Log\Context\GetContext; +use BEAR\QueryRepository\Log\Context\PurgeContext; use BEAR\QueryRepository\Log\Context\PutSkippedContext; use BEAR\RepositoryModule\Annotation\CacheLog; use BEAR\Resource\ResourceObject; @@ -40,6 +41,8 @@ { public function __construct( private QueryRepositoryInterface $repository, + private ResourceStorageInterface $storage, + private UriTagInterface $uriTag, #[CacheLog] private SemanticLoggerInterface $logger = new NullSemanticLogger(), ) { @@ -91,7 +94,9 @@ public function invoke(MethodInvocation $invocation) // Record the actual non-200 code; without it the purge below reads // as if a 203 and a 404 were the same thing. $this->logger->event(new PutSkippedContext((string) $ro->uri, 'error-code', $ro->code)); - $this->repository->purge($ro->uri); + // Only this URI's own tag: purge() would also evict its query variants. + $this->logger->event(new PurgeContext((string) $ro->uri)); + $this->storage->invalidateTags([($this->uriTag)($ro->uri)]); return $ro; } diff --git a/tests/Fake/fake-app/src/Resource/App/PagedErrorOnBare.php b/tests/Fake/fake-app/src/Resource/App/PagedErrorOnBare.php new file mode 100644 index 00000000..2ce07e5b --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/App/PagedErrorOnBare.php @@ -0,0 +1,25 @@ +code = self::$fail && $page === 1 ? 503 : 200; + $this->body = ['page' => $page, 'time' => \microtime(true)]; + + return $this; + } +} diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index bf2b6f2d..f3d64b17 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -8,6 +8,7 @@ use BEAR\Resource\ResourceInterface; use BEAR\Resource\ResourceObject; use BEAR\Resource\Uri; +use FakeVendor\HelloWorld\Resource\App\PagedErrorOnBare; use PHPUnit\Framework\TestCase; use Ray\Di\Injector; use ReflectionMethod; @@ -265,4 +266,20 @@ public function testDynamicDonutCarriesTheGroupTagInItsCdnHeader(): void $this->assertStringContainsString('grp-_html_blog-posting-donut_', $ro->headers['Surrogate-Key']); } + + /** An error response drops only its own URI's entry; siblings sharing its group tag stay cached. */ + public function testErrorOnBareUriKeepsQueryVariantsCached(): void + { + $page2 = $this->time('app://self/paged-error-on-bare?page=2'); + $this->assertSame($page2, $this->time('app://self/paged-error-on-bare?page=2'), 'precondition: warm cache'); + + PagedErrorOnBare::$fail = true; + try { + $this->assertSame(503, $this->resource->get('app://self/paged-error-on-bare')->code); + } finally { + PagedErrorOnBare::$fail = false; + } + + $this->assertSame($page2, $this->time('app://self/paged-error-on-bare?page=2')); + } } From 1036de64033580c97998568d28e21fee63bdab8c Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Tue, 3 Nov 2026 09:13:22 +0900 Subject: [PATCH 22/39] Pin the error path's own-tag invalidation in its test The test only checked that query variants survive an error on the bare URI, so it also passed with the invalidation removed. It now also checks that an entry tagged with the bare URI's own tag is dropped. Also states the ResourceDonut::refresh() comment as a current fact. --- src/ResourceDonut.php | 2 +- tests/QueryVariantPurgeTest.php | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ResourceDonut.php b/src/ResourceDonut.php index 6397aa9d..92355415 100644 --- a/src/ResourceDonut.php +++ b/src/ResourceDonut.php @@ -107,7 +107,7 @@ public function refresh(ResourceInterface $resource, ResourceObject $ro): Resour $ro->code = $this->code; } - // $this->headers predates the group tag; restore the stored tags. + // $this->headers lacks the group tag; add the stored tags. $storedTags = $this->getStorageTags(); if ($storedTags !== []) { $existing = isset($ro->headers[Header::SURROGATE_KEY]) ? explode(' ', $ro->headers[Header::SURROGATE_KEY]) : []; diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index f3d64b17..58db0b08 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -267,11 +267,16 @@ public function testDynamicDonutCarriesTheGroupTagInItsCdnHeader(): void $this->assertStringContainsString('grp-_html_blog-posting-donut_', $ro->headers['Surrogate-Key']); } - /** An error response drops only its own URI's entry; siblings sharing its group tag stay cached. */ + /** An error response invalidates its own URI's tag (e.g. a parent embedding it) but not the group tag. */ public function testErrorOnBareUriKeepsQueryVariantsCached(): void { $page2 = $this->time('app://self/paged-error-on-bare?page=2'); $this->assertSame($page2, $this->time('app://self/paged-error-on-bare?page=2'), 'precondition: warm cache'); + $roPool = (new ReflectionProperty($this->storage, 'roPool'))->getValue($this->storage); + assert($roPool instanceof TagAwareAdapterInterface); + $dependent = $roPool->getItem('dependent-on-bare'); + $dependent->set(true)->tag((new UriTag())(new Uri('app://self/paged-error-on-bare'))); + $this->assertTrue($roPool->save($dependent), 'precondition: dependent entry stored'); PagedErrorOnBare::$fail = true; try { @@ -280,6 +285,7 @@ public function testErrorOnBareUriKeepsQueryVariantsCached(): void PagedErrorOnBare::$fail = false; } + $this->assertFalse($roPool->hasItem('dependent-on-bare')); $this->assertSame($page2, $this->time('app://self/paged-error-on-bare?page=2')); } } From 17da888464a17b9a487a50214890518915f63177 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Tue, 3 Nov 2026 10:09:31 +0900 Subject: [PATCH 23/39] CHANGELOG: a purge URI that spells out a default value is not coarsened --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c4703c..9a4022fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with a coarser group tag matching what `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body and the etagPool's If-None-Match validator for both `#[Cacheable]` value resources and `#[CacheableResponse]`/`#[DonutCache]` pages, and the latter's CDN-facing `Surrogate-Key` header too, including after a page's state is recomposed by `ResourceDonut::refresh()` (#255). `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only that one. Trade-off: because every variant now shares the group tag, a write still purges all of them together through this auto-purge path - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. +- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with a coarser group tag matching what `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body and the etagPool's If-None-Match validator for both `#[Cacheable]` value resources and `#[CacheableResponse]`/`#[DonutCache]` pages, and the latter's CDN-facing `Surrogate-Key` header too, including after a page's state is recomposed by `ResourceDonut::refresh()` (#255). A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and still reaches only that exact entry; write it without the parameter to reach every variant. `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only that one. Trade-off: because every variant now shares the group tag, a write still purges all of them together through this auto-purge path - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. ## [1.18.0] - 2026-10-29 From 47eaf0fa1595d86193586a75cd4ecb3a7859dcc4 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Tue, 3 Nov 2026 10:15:44 +0900 Subject: [PATCH 24/39] CHANGELOG: describe the auto-purge with real write targets, not edits of a page --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a4022fb..93f0667b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A cache entry for a GET with an extra query (`?page=2`, or a query `onGet()` does not declare at all) is now also tagged with a coarser group tag matching what `#[Purge]`'s own URI and `MatchQuery`-based auto-purge target, so a purge aimed at the bare/required-only URI reaches every query variant instead of leaving them stale - this covers the roPool body and the etagPool's If-None-Match validator for both `#[Cacheable]` value resources and `#[CacheableResponse]`/`#[DonutCache]` pages, and the latter's CDN-facing `Surrogate-Key` header too, including after a page's state is recomposed by `ResourceDonut::refresh()` (#255). A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and still reaches only that exact entry; write it without the parameter to reach every variant. `RefreshSameCommand`/`DonutCommandInterceptor`'s automatic purge after a write now targets that same coarsened URI too, instead of `MatchQuery`'s exact match - so a write whose own URI happens to carry a defaulted param (e.g. a `PUT`/`POST` to `?page=2`) still invalidates every other variant (`?page=1`, the bare URI), not only that one. Trade-off: because every variant now shares the group tag, a write still purges all of them together through this auto-purge path - editing `?page=1` also drops the still-unchanged `?page=2` entry, not just its own. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and reaches only that exact entry; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. ## [1.18.0] - 2026-10-29 From 562d82a5107d873e47cc4ac82123f0d99f059b45 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 09:11:48 +0900 Subject: [PATCH 25/39] Mark BaseUriTagInterface @internal as BaseUriTag is The group tag must match across every store and the purge, so neither type is an extension point; the binding stays. --- src/BaseUriTagInterface.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/BaseUriTagInterface.php b/src/BaseUriTagInterface.php index db93d4d1..0267bc0b 100644 --- a/src/BaseUriTagInterface.php +++ b/src/BaseUriTagInterface.php @@ -10,6 +10,7 @@ * The group tag of a GET resource: its URI tag without the default-valued onGet() parameters. * A query-variant cache entry carries it besides its own tag, and a purge of the bare URI targets it. * + * @internal * @see https://github.com/bearsunday/BEAR.QueryRepository/issues/255 */ interface BaseUriTagInterface From dfafd0bedcd19be79e06a6b52927378c8b88cfa3 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 09:47:24 +0900 Subject: [PATCH 26/39] Reach a parent that embeds a query variant, and keep snake_case keys in the group A parent depended on a child only through the child's exact URI tag, so a bare purge removed a variant such as ?id=1&page=2 but left the #[Cacheable] or donut page that embeds it stale (#255 one level up). CacheDependency now copies the child's group tag to the parent as well. BaseUriTag looked up required parameters by their camelCase name only, so a request with ?user_id=1 lost that key and a purge of ?user_id=1 missed its variants. It now keeps the key as the request spelled it, as BEAR.Resource binds it. CHANGELOG: the CDN Surrogate-Key header carries the group tag on donut pages only, and entries stored before this version need a new CacheVersionModule version to be reached at once. A purge URI that spells a default value stays uncoarsened; a test pins it. --- CHANGELOG.md | 2 +- src/BaseUriTag.php | 19 ++++++- src/CacheDependency.php | 7 ++- src/DonutRepository.php | 2 +- tests/BaseUriTagTest.php | 45 +++++++++++++++ tests/CacheDependencyTest.php | 2 +- .../src/Resource/App/SnakeQueryParam.php | 20 +++++++ .../Resource/Page/Html/VariantDonutParent.php | 23 ++++++++ .../src/Resource/Page/PagedValueParent.php | 22 ++++++++ .../src/Resource/Page/VariantParent.php | 22 ++++++++ tests/QueryVariantPurgeTest.php | 55 +++++++++++++++++++ 11 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 tests/BaseUriTagTest.php create mode 100644 tests/Fake/fake-app/src/Resource/App/SnakeQueryParam.php create mode 100644 tests/Fake/fake-app/src/Resource/Page/Html/VariantDonutParent.php create mode 100644 tests/Fake/fake-app/src/Resource/Page/PagedValueParent.php create mode 100644 tests/Fake/fake-app/src/Resource/Page/VariantParent.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f0667b..87a829e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and reaches only that exact entry; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, so the purge reaches the parent too (#257); the automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and reaches only that exact entry; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. Entries stored before this version carry no group tag until they are rewritten; to have a bare purge reach them at once, deploy with a new `CacheVersionModule` version. ## [1.18.0] - 2026-10-29 diff --git a/src/BaseUriTag.php b/src/BaseUriTag.php index 8b0bb343..325639c0 100644 --- a/src/BaseUriTag.php +++ b/src/BaseUriTag.php @@ -8,7 +8,10 @@ use Override; use ReflectionMethod; +use function ltrim; use function method_exists; +use function preg_replace; +use function strtolower; /** * {@inheritDoc} @@ -17,6 +20,7 @@ * * Drops every default-valued onGet() parameter, supplied or not (MatchQuery keeps the supplied ones); * required parameters stay: `onGet(int $userId, int $page = 1)` maps `?userId=1&page=2` to `?userId=1`. + * A required parameter keeps the key the request spelled, `userId` or the `user_id` BEAR.Resource also binds. */ final class BaseUriTag implements BaseUriTagInterface { @@ -41,12 +45,17 @@ public function __invoke(ResourceObject $ro): string $query = $ro->uri->query; $baseQuery = []; foreach ((new ReflectionMethod($ro, 'onGet'))->getParameters() as $parameter) { - if ($parameter->isDefaultValueAvailable() || ! isset($query[$parameter->name])) { + if ($parameter->isDefaultValueAvailable()) { + continue; + } + + $key = isset($query[$parameter->name]) ? $parameter->name : self::snakeName($parameter->name); + if (! isset($query[$key])) { continue; } /** @psalm-suppress MixedAssignment */ - $baseQuery[$parameter->name] = $query[$parameter->name]; + $baseQuery[$key] = $query[$key]; } $baseUri = clone $ro->uri; @@ -54,4 +63,10 @@ public function __invoke(ResourceObject $ro): string return self::GROUP_TAG_PREFIX . ($this->uriTag)($baseUri); } + + /** The conversion BEAR.Resource applies when it binds a query to a parameter */ + private static function snakeName(string $name): string + { + return ltrim(strtolower((string) preg_replace('/[A-Z]/', '_\0', $name)), '_'); + } } diff --git a/src/CacheDependency.php b/src/CacheDependency.php index c08b228f..1e91d625 100644 --- a/src/CacheDependency.php +++ b/src/CacheDependency.php @@ -20,17 +20,22 @@ /** @internal */ final readonly class CacheDependency implements CacheDependencyInterface { + private BaseUriTagInterface $baseUriTag; + public function __construct( private UriTagInterface $uriTag, #[CacheLog] private SemanticLoggerInterface $logger = new NullSemanticLogger(), + BaseUriTagInterface|null $baseUriTag = null, ) { + $this->baseUriTag = $baseUriTag ?? new BaseUriTag($uriTag); } #[Override] public function depends(ResourceObject $from, ResourceObject $to): void { - $childTags = ($this->uriTag)($to->uri); + // The group tag too, so a purge of the child's bare URI reaches a parent that embeds one of its variants (#257). + $childTags = sprintf('%s %s', ($this->uriTag)($to->uri), ($this->baseUriTag)($to)); if (isset($to->headers[Header::SURROGATE_KEY])) { $childTags .= sprintf(' %s', $to->headers[Header::SURROGATE_KEY]); } diff --git a/src/DonutRepository.php b/src/DonutRepository.php index 4639a7fe..d06f084d 100644 --- a/src/DonutRepository.php +++ b/src/DonutRepository.php @@ -51,7 +51,7 @@ public function __construct( private DeclaredCacheControl $declaredCacheControl = new DeclaredCacheControl(), ) { // Same UriTag as templateKeys(), so a material's tag matches the format purge() emits. - $this->cacheDependency = $cacheDependency ?? new CacheDependency($this->uriTag, $this->logger); + $this->cacheDependency = $cacheDependency ?? new CacheDependency($this->uriTag, $this->logger, $this->baseUriTag); } #[Override] diff --git a/tests/BaseUriTagTest.php b/tests/BaseUriTagTest.php new file mode 100644 index 00000000..39f79a96 --- /dev/null +++ b/tests/BaseUriTagTest.php @@ -0,0 +1,45 @@ +uri = new Uri('app://self/item?id=1&page=2'); + $uriTag = new UriTag(); + + set_error_handler(static function (int $errno, string $errstr): never { + throw new ErrorException($errstr, 0, $errno); + }); + try { + $groupTag = (new BaseUriTag($uriTag))($ro); + } finally { + restore_error_handler(); + } + + $this->assertSame('grp-' . $uriTag(new Uri('app://self/item?id=1')), $groupTag); + } +} diff --git a/tests/CacheDependencyTest.php b/tests/CacheDependencyTest.php index fed95eb9..ae2a046e 100644 --- a/tests/CacheDependencyTest.php +++ b/tests/CacheDependencyTest.php @@ -309,6 +309,6 @@ public function testDependsOnChildTagsHaveNoDuplicateWhenTheChildsOwnKeyEqualsIt $context = json_decode($edges[0], true); assert(is_array($context)); $childTags = $context['childTags']; - $this->assertSame([$uriTag($child->uri)], $childTags); + $this->assertSame([$uriTag($child->uri), (new BaseUriTag($uriTag))($child)], $childTags); } } diff --git a/tests/Fake/fake-app/src/Resource/App/SnakeQueryParam.php b/tests/Fake/fake-app/src/Resource/App/SnakeQueryParam.php new file mode 100644 index 00000000..13cf2187 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/App/SnakeQueryParam.php @@ -0,0 +1,20 @@ +body = ['userId' => $userId, 'page' => $page, 'time' => \microtime(true)]; + + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/Html/VariantDonutParent.php b/tests/Fake/fake-app/src/Resource/Page/Html/VariantDonutParent.php new file mode 100644 index 00000000..42e3acb5 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/Html/VariantDonutParent.php @@ -0,0 +1,23 @@ +body['childTime'] = $this['child']['time']; + $this->body['time'] = \microtime(true); + + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/PagedValueParent.php b/tests/Fake/fake-app/src/Resource/Page/PagedValueParent.php new file mode 100644 index 00000000..5536c8cc --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/PagedValueParent.php @@ -0,0 +1,22 @@ +body['time'] = \microtime(true); + + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/VariantParent.php b/tests/Fake/fake-app/src/Resource/Page/VariantParent.php new file mode 100644 index 00000000..c9b77cb1 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/VariantParent.php @@ -0,0 +1,22 @@ +body['time'] = \microtime(true); + + return $this; + } +} diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index 58db0b08..80d4099b 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -288,4 +288,59 @@ public function testErrorOnBareUriKeepsQueryVariantsCached(): void $this->assertFalse($roPool->hasItem('dependent-on-bare')); $this->assertSame($page2, $this->time('app://self/paged-error-on-bare?page=2')); } + + /** A parent depends on its child's group, so a bare purge of the child reaches a parent that embeds a variant. */ + public function testBarePurgeReachesACacheableParentEmbeddingAQueryVariant(): void + { + $parent = $this->time('page://self/variant-parent'); + $this->assertSame($parent, $this->time('page://self/variant-parent'), 'precondition: warm cache'); + + $this->repository->purge(new Uri('app://self/optional-param?id=1')); + + $this->assertNotSame($parent, $this->time('page://self/variant-parent')); + } + + public function testBarePurgeReachesADonutPageReadingAQueryVariant(): void + { + $page = (string) $this->resource->get('page://self/html/variant-donut-parent'); + $this->assertSame($page, (string) $this->resource->get('page://self/html/variant-donut-parent'), 'precondition: warm cache'); + + $this->repository->purge(new Uri('app://self/optional-param?id=1')); + + $this->assertNotSame($page, (string) $this->resource->get('page://self/html/variant-donut-parent')); + } + + public function testWriteToTheChildReachesAParentEmbeddingAnotherQueryVariant(): void + { + $parent = $this->time('page://self/paged-value-parent'); + $this->assertSame($parent, $this->time('page://self/paged-value-parent'), 'precondition: warm cache'); + + $this->resource->put('app://self/paged-value', ['page' => 1]); + + $this->assertNotSame($parent, $this->time('page://self/paged-value-parent')); + } + + /** A purge URI that spells a default value is not coarsened: it reaches that entry only. */ + public function testPurgeSpellingADefaultValueReachesThatVariantOnly(): void + { + $page1 = $this->time('app://self/optional-param?id=1&page=1'); + $page2 = $this->time('app://self/optional-param?id=1&page=2'); + + $this->repository->purge(new Uri('app://self/optional-param?id=1&page=1')); + + $this->assertNotSame($page1, $this->time('app://self/optional-param?id=1&page=1')); + $this->assertSame($page2, $this->time('app://self/optional-param?id=1&page=2')); + } + + /** BEAR.Resource binds `?user_id=` to `$userId`; the group keeps the required key as the request spelled it. */ + public function testBarePurgeWithSnakeCaseKeysReachesThatUsersVariantsOnly(): void + { + $user1 = $this->time('app://self/snake-query-param?user_id=1&page=2'); + $user2 = $this->time('app://self/snake-query-param?user_id=2&page=2'); + + $this->repository->purge(new Uri('app://self/snake-query-param?user_id=1')); + + $this->assertNotSame($user1, $this->time('app://self/snake-query-param?user_id=1&page=2')); + $this->assertSame($user2, $this->time('app://self/snake-query-param?user_id=2&page=2')); + } } From 3ba6b59b395e2c04da0e298a66db61f6c0ed31a2 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 10:52:08 +0900 Subject: [PATCH 27/39] Reach a page that renders a query variant as a hole A hole's tags reach the page's view and CDN Surrogate-Key header through SurrogateKeys, which added the hole's URI tag but not its group tag, so a bare purge of the child left a #[CacheableResponse] page showing ?id=1&page=2 stale. SurrogateKeys now adds the hole's group tag too. ResourceDonut::refresh() added a hole's tags only when the hole carried a Surrogate-Key header, while the first write adds them for every hole. A hole cached by its ETag alone, such as a plain #[Cacheable] child, lost its tags on the first recomposition and could not be purged again. refresh() now adds them for every hole. --- CHANGELOG.md | 3 ++- src/ResourceDonut.php | 5 +--- src/SurrogateKeys.php | 5 +++- tests/DonutMaterialDependencyTest.php | 24 +++++++++++++++++++ .../Resource/Page/Html/VariantHoleParent.php | 22 +++++++++++++++++ .../var/templates/App/OptionalParam.html.twig | 1 + .../Page/Html/VariantHoleParent.html.twig | 1 + tests/SurrogateKeysTest.php | 6 ++--- 8 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 tests/Fake/fake-app/src/Resource/Page/Html/VariantHoleParent.php create mode 100644 tests/Fake/fake-app/var/templates/App/OptionalParam.html.twig create mode 100644 tests/Fake/fake-app/var/templates/Page/Html/VariantHoleParent.html.twig diff --git a/CHANGELOG.md b/CHANGELOG.md index 87a829e8..5c2e90cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, so the purge reaches the parent too (#257); the automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and reaches only that exact entry; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. Entries stored before this version carry no group tag until they are rewritten; to have a bare purge reach them at once, deploy with a new `CacheVersionModule` version. +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, so the purge reaches the parent too (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and reaches only that exact entry; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. Entries stored before this version carry no group tag until they are rewritten; to have a bare purge reach them at once, deploy with a new `CacheVersionModule` version. +- A recomposed donut page kept the tags only of its holes that carry a `Surrogate-Key` header, so a hole cached by its ETag alone, such as a plain `#[Cacheable]` child, could be purged once but not again. A recomposed page now keeps every hole's tags, as the first write does. ## [1.18.0] - 2026-10-29 diff --git a/src/ResourceDonut.php b/src/ResourceDonut.php index 92355415..a3ab7941 100644 --- a/src/ResourceDonut.php +++ b/src/ResourceDonut.php @@ -10,7 +10,6 @@ use BEAR\Resource\ResourceObject; use Throwable; -use function array_key_exists; use function array_unique; use function assert; use function explode; @@ -93,9 +92,7 @@ public function refresh(ResourceInterface $resource, ResourceObject $ro): Resour throw CacheStoreFailure::from($e); } - if (array_key_exists(Header::SURROGATE_KEY, $ro->headers)) { - $etags->addTag($ro); - } + $etags->addTag($ro); return (string) $ro->view; }, $this->template); diff --git a/src/SurrogateKeys.php b/src/SurrogateKeys.php index 9b1a52bc..fa37a28e 100644 --- a/src/SurrogateKeys.php +++ b/src/SurrogateKeys.php @@ -18,19 +18,22 @@ final class SurrogateKeys /** @var list */ private array $surrogateKeys; private readonly UriTagInterface $uriTag; + private readonly BaseUriTagInterface $baseUriTag; public function __construct(AbstractUri $uri, string|null $baseTag = null) { $this->uriTag = new UriTag(); + $this->baseUriTag = new BaseUriTag($this->uriTag); $this->surrogateKeys = $baseTag === null ? [($this->uriTag)($uri)] : [($this->uriTag)($uri), $baseTag]; } /** - * Add etag of embedded resource + * Add the tags of an embedded resource, including its group tag so that a purge of its bare URI reaches the page */ public function addTag(ResourceObject $ro): void { $this->surrogateKeys[] = ($this->uriTag)($ro->uri); + $this->surrogateKeys[] = ($this->baseUriTag)($ro); if (array_key_exists(Header::SURROGATE_KEY, $ro->headers)) { $this->surrogateKeys = array_merge($this->surrogateKeys, explode(' ', $ro->headers[Header::SURROGATE_KEY])); } diff --git a/tests/DonutMaterialDependencyTest.php b/tests/DonutMaterialDependencyTest.php index 08010ae0..007cc266 100644 --- a/tests/DonutMaterialDependencyTest.php +++ b/tests/DonutMaterialDependencyTest.php @@ -338,6 +338,30 @@ public function fromAssoc(string $uriTemplate, array $assoc): string $this->assertStringContainsString('customtag_page___self_html_comment', $result->headers[Header::SURROGATE_KEY] ?? '', "the default CacheDependency must tag the child with THIS repository's own UriTag, not a bare new UriTag()"); } + /** The hole's group tag reaches the view, so a purge of the hole's bare URI recomposes the page. */ + public function testBarePurgeReachesAPageRenderingAQueryVariantAsAHole(): void + { + $page = (string) $this->resource->get('page://self/html/variant-hole-parent'); + $this->assertSame($page, (string) $this->resource->get('page://self/html/variant-hole-parent'), 'precondition: warm cache'); + + $this->queryRepository->purge(new Uri('app://self/optional-param?id=1')); + + $this->assertNotSame($page, (string) $this->resource->get('page://self/html/variant-hole-parent')); + } + + /** A recomposed page keeps its hole's tags, as the first write does, so the hole can be purged again. */ + public function testPurgeOfAHoleReachesThePageAgainAfterItWasRecomposed(): void + { + $first = (string) $this->resource->get('page://self/html/variant-hole-parent'); + $this->queryRepository->purge(new Uri('app://self/optional-param?id=1&page=2')); + $recomposed = (string) $this->resource->get('page://self/html/variant-hole-parent'); + $this->assertNotSame($first, $recomposed, 'precondition: the first purge recomposes the page'); + + $this->queryRepository->purge(new Uri('app://self/optional-param?id=1&page=2')); + + $this->assertNotSame($recomposed, (string) $this->resource->get('page://self/html/variant-hole-parent')); + } + /** * @param array $tree * diff --git a/tests/Fake/fake-app/src/Resource/Page/Html/VariantHoleParent.php b/tests/Fake/fake-app/src/Resource/Page/Html/VariantHoleParent.php new file mode 100644 index 00000000..d8024581 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/Html/VariantHoleParent.php @@ -0,0 +1,22 @@ +body['time'] = \microtime(true); + + return $this; + } +} diff --git a/tests/Fake/fake-app/var/templates/App/OptionalParam.html.twig b/tests/Fake/fake-app/var/templates/App/OptionalParam.html.twig new file mode 100644 index 00000000..3160c808 --- /dev/null +++ b/tests/Fake/fake-app/var/templates/App/OptionalParam.html.twig @@ -0,0 +1 @@ +{{ id }}:{{ page }}:{{ time }} \ No newline at end of file diff --git a/tests/Fake/fake-app/var/templates/Page/Html/VariantHoleParent.html.twig b/tests/Fake/fake-app/var/templates/Page/Html/VariantHoleParent.html.twig new file mode 100644 index 00000000..fd1af381 --- /dev/null +++ b/tests/Fake/fake-app/var/templates/Page/Html/VariantHoleParent.html.twig @@ -0,0 +1 @@ +variant-hole-parent:{{ child }} \ No newline at end of file diff --git a/tests/SurrogateKeysTest.php b/tests/SurrogateKeysTest.php index 3ca95bda..f851929c 100644 --- a/tests/SurrogateKeysTest.php +++ b/tests/SurrogateKeysTest.php @@ -56,7 +56,7 @@ public function testSetSurrogateHeader(): void }; $ro->uri = $uri; $etags->setSurrogateHeader($ro); - $this->assertSame('_foo_ _foo1_ a b _foo2_', $ro->headers[Header::SURROGATE_KEY]); + $this->assertSame('_foo_ _foo1_ grp-_foo1_ a b _foo2_ grp-_foo2_', $ro->headers[Header::SURROGATE_KEY]); $ro = $this->resource->get('page://self/html/page-surrogate-key'); $this->assertStringContainsString('page-tag', $ro->headers[Header::SURROGATE_KEY]); @@ -71,7 +71,7 @@ public function testOnePurgeKey(): void $foo->uri = $uri; $etags->addTag($foo); $etags->setSurrogateHeader($foo); - $this->assertSame('_foo_', $foo->headers[Header::SURROGATE_KEY]); + $this->assertSame('_foo_ grp-_foo_', $foo->headers[Header::SURROGATE_KEY]); } public function testSetSurrogateHeaderDeduplicatesWithManualKeys(): void @@ -90,6 +90,6 @@ public function testSetSurrogateHeaderDeduplicatesWithManualKeys(): void }; $ro->uri = $uri; $etags->setSurrogateHeader($ro); - $this->assertSame('shared-tag _foo_ _foo1_', $ro->headers[Header::SURROGATE_KEY]); + $this->assertSame('shared-tag _foo_ _foo1_ grp-_foo1_', $ro->headers[Header::SURROGATE_KEY]); } } From e1be240a62bc96afca94294b922e784a192497dd Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 10:52:08 +0900 Subject: [PATCH 28/39] Do not restore an injector serialized by 1.18.0 The repositories, CacheInterceptor and CacheDependency gained a constructor dependency, and restoring their 1.18.0 payloads leaves it uninitialized, so only ResourceStorage could fall back. Say in CHANGELOG that the injector is rebuilt on deploy instead, and drop the ResourceStorage fallback that made the restore look supported. --- CHANGELOG.md | 2 +- src/ResourceStorage.php | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2e90cb..e37c1b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, so the purge reaches the parent too (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and reaches only that exact entry; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. Entries stored before this version carry no group tag until they are rewritten; to have a bare purge reach them at once, deploy with a new `CacheVersionModule` version. +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, so the purge reaches the parent too (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and reaches only that exact entry; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. Entries stored before this version carry no group tag until they are rewritten; to have a bare purge reach them at once, deploy with a new `CacheVersionModule` version. The repositories, `CacheInterceptor` and `CacheDependency` take a new constructor dependency, so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. - A recomposed donut page kept the tags only of its holes that carry a `Surrogate-Key` header, so a hole cached by its ETag alone, such as a plain `#[Cacheable]` child, could be purged once but not again. A recomposed page now keeps every hole's tags, as the first write does. ## [1.18.0] - 2026-10-29 diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php index 51dd6657..f897330e 100644 --- a/src/ResourceStorage.php +++ b/src/ResourceStorage.php @@ -47,7 +47,7 @@ * roProvider:ProviderInterface, * etagProvider: ProviderInterface, * serverContext: ServerContextInterface, - * baseUriTag?: BaseUriTagInterface, + * baseUriTag: BaseUriTagInterface, * } * @internal */ @@ -445,8 +445,7 @@ public function __unserialize(array $data): void // Stateless, so rebuilt rather than carried: left out of the payload it stays // uninitialized, and the first save after a compiled-injector restore throws. $this->evaluateBody = new ResourceBodyEvaluator(); - // Falls back to the default (built from the restored uriTag) when a payload lacks it. - $this->baseUriTag = $data['baseUriTag'] ?? new BaseUriTag($this->uriTag); + $this->baseUriTag = $data['baseUriTag']; $this->initializePools($data['roProvider'], $data['etagProvider']); } } From 0c189ba9c76102d4b3c92b7eb8cae57f8c8037d8 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 11:47:32 +0900 Subject: [PATCH 29/39] Pin that a camelCase required key stays camelCase in the group tag Only snake_case keys were tested, so BaseUriTag always converting a key to snake_case passed the suite while a purge of ?userId=1 stopped reaching that user's variants. --- .../fake-app/src/Resource/App/SnakeQueryParam.php | 2 +- tests/QueryVariantPurgeTest.php | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/Fake/fake-app/src/Resource/App/SnakeQueryParam.php b/tests/Fake/fake-app/src/Resource/App/SnakeQueryParam.php index 13cf2187..fce5a45f 100644 --- a/tests/Fake/fake-app/src/Resource/App/SnakeQueryParam.php +++ b/tests/Fake/fake-app/src/Resource/App/SnakeQueryParam.php @@ -7,7 +7,7 @@ use BEAR\RepositoryModule\Annotation\Cacheable; use BEAR\Resource\ResourceObject; -/** Requested with snake_case keys (`?user_id=1`), which BEAR.Resource binds to the camelCase parameters. */ +/** Requested with camelCase (`?userId=1`) or snake_case (`?user_id=1`) keys; BEAR.Resource binds both to `$userId`. */ #[Cacheable] class SnakeQueryParam extends ResourceObject { diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index 80d4099b..7b55abc7 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -343,4 +343,17 @@ public function testBarePurgeWithSnakeCaseKeysReachesThatUsersVariantsOnly(): vo $this->assertNotSame($user1, $this->time('app://self/snake-query-param?user_id=1&page=2')); $this->assertSame($user2, $this->time('app://self/snake-query-param?user_id=2&page=2')); } + + /** A camelCase key (`?userId=`) stays camelCase in the group. */ + public function testBarePurgeWithCamelCaseKeysReachesThatUsersVariantsOnly(): void + { + $user1 = $this->time('app://self/snake-query-param?userId=1&page=2'); + $user2 = $this->time('app://self/snake-query-param?userId=2&page=2'); + $this->assertSame($user1, $this->time('app://self/snake-query-param?userId=1&page=2'), 'precondition: warm cache'); + + $this->repository->purge(new Uri('app://self/snake-query-param?userId=1')); + + $this->assertNotSame($user1, $this->time('app://self/snake-query-param?userId=1&page=2')); + $this->assertSame($user2, $this->time('app://self/snake-query-param?userId=2&page=2')); + } } From b6656ac445ec19cfeca992f2f1680ee7852214b0 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 11:59:07 +0900 Subject: [PATCH 30/39] CHANGELOG: a parent's CDN Surrogate-Key header carries the child's group tag A #[Cacheable] parent embedding ?id=1&page=2 answers with Surrogate-Key: _optional-param_id=1_page=2 grp-_optional-param_id=1, so the CDN header is not a donut-only carrier of the group tag. Also drops the issue number from the CacheDependency comment. --- CHANGELOG.md | 2 +- src/CacheDependency.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e37c1b25..9b2bce32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, so the purge reaches the parent too (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and reaches only that exact entry; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. Entries stored before this version carry no group tag until they are rewritten; to have a bare purge reach them at once, deploy with a new `CacheVersionModule` version. The repositories, `CacheInterceptor` and `CacheDependency` take a new constructor dependency, so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and reaches only that exact entry; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. Entries stored before this version carry no group tag until they are rewritten; to have a bare purge reach them at once, deploy with a new `CacheVersionModule` version. The repositories, `CacheInterceptor` and `CacheDependency` take a new constructor dependency, so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. - A recomposed donut page kept the tags only of its holes that carry a `Surrogate-Key` header, so a hole cached by its ETag alone, such as a plain `#[Cacheable]` child, could be purged once but not again. A recomposed page now keeps every hole's tags, as the first write does. ## [1.18.0] - 2026-10-29 diff --git a/src/CacheDependency.php b/src/CacheDependency.php index 1e91d625..013ee0d4 100644 --- a/src/CacheDependency.php +++ b/src/CacheDependency.php @@ -34,7 +34,7 @@ public function __construct( #[Override] public function depends(ResourceObject $from, ResourceObject $to): void { - // The group tag too, so a purge of the child's bare URI reaches a parent that embeds one of its variants (#257). + // The group tag too, so a purge of the child's bare URI reaches a parent that embeds one of its variants. $childTags = sprintf('%s %s', ($this->uriTag)($to->uri), ($this->baseUriTag)($to)); if (isset($to->headers[Header::SURROGATE_KEY])) { $childTags .= sprintf(' %s', $to->headers[Header::SURROGATE_KEY]); From b79616fd2f21d39f61bfe9e724141b8c32db1a23 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 12:49:38 +0900 Subject: [PATCH 31/39] CHANGELOG: require a new CacheVersionModule version, list ResourceStorage A donut template stored by 1.18.0 keeps lacking the group tag after its page is recomposed (refresh() takes the page's group tag only from the stored template tags), so a bare purge never reaches it; a new cache version is required rather than an option for reaching old entries sooner. ResourceStorage was missing from the classes whose constructor changed, and CacheDependency's new parameter is optional. The own-tag-only paths invalidate a tag, which also reaches the pages embedding that URI, rather than dropping a single entry. The coarsening comments state what the code does instead of asking the reader to keep two copies in sync, and the initial-save test docblock no longer claims a causation the test does not check. --- CHANGELOG.md | 2 +- src/DonutCommandInterceptor.php | 2 +- src/RefreshSameCommand.php | 2 +- tests/QueryVariantPurgeTest.php | 3 +-- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b2bce32..08e564e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and reaches only that exact entry; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still drops only its own URI's entry, so an error on the bare URI leaves the cached query variants in place. Entries stored before this version carry no group tag until they are rewritten; to have a bare purge reach them at once, deploy with a new `CacheVersionModule` version. The repositories, `CacheInterceptor` and `CacheDependency` take a new constructor dependency, so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. Deploy this version with a new `CacheVersionModule` version: entries stored by an earlier version carry no group tag, and a donut template stored by one keeps lacking it even when its page is recomposed, so a bare purge would not reach them. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. - A recomposed donut page kept the tags only of its holes that carry a `Surrogate-Key` header, so a hole cached by its ETag alone, such as a plain `#[Cacheable]` child, could be purged once but not again. A recomposed page now keeps every hole's tags, as the first write does. ## [1.18.0] - 2026-10-29 diff --git a/src/DonutCommandInterceptor.php b/src/DonutCommandInterceptor.php index 1ad61252..bab8ad8a 100644 --- a/src/DonutCommandInterceptor.php +++ b/src/DonutCommandInterceptor.php @@ -81,7 +81,7 @@ public function refreshDonutAndState(ResourceObject $ro): void { $getQuery = ($this->matchQuery)($ro); $delUri = clone $ro->uri; - // Coarsened like BaseUriTag (keep the two in sync); the refresh below still uses the exact $getQuery. + // Default-valued parameters dropped, as BaseUriTag drops them; the refresh below still uses the exact $getQuery. $delUri->query = $this->baseQuery($ro, $getQuery); $this->repository->purge($delUri); diff --git a/src/RefreshSameCommand.php b/src/RefreshSameCommand.php index 61518c71..e2f2a657 100644 --- a/src/RefreshSameCommand.php +++ b/src/RefreshSameCommand.php @@ -58,7 +58,7 @@ public function command(MethodInvocation $invocation, ResourceObject $ro): void } $delUri = clone $ro->uri; - // Coarsened like BaseUriTag (keep the two in sync); the refresh below still uses the exact $getQuery. + // Default-valued parameters dropped, as BaseUriTag drops them; the refresh below still uses the exact $getQuery. $delUri->query = $this->baseQuery($ro, $getQuery); // delete data in repository diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index 7b55abc7..5e1a2d06 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -243,8 +243,7 @@ public function testDonutRefreshKeepsTheGroupTagOnTheEtagValidator(): void $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); } - /** On the first, never-refreshed save, doPutStatic()'s SurrogateKeys is what tags the etagPool entry, - * so it must carry the group tag itself. */ + /** On the first, never-refreshed save, doPutStatic()'s SurrogateKeys tags the etagPool entry with the page's group tag. */ public function testDonutInitialSaveTagsTheEtagValidatorWithTheGroupTag(): void { assert($this->storage instanceof ScopedValidatorInterface); From ccf988ba874834c720fece8264b3a2b7bf51c3c6 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 13:02:51 +0900 Subject: [PATCH 32/39] CHANGELOG: narrow the old-template claim, state the snake_case auto-purge limit A page recomposed from an earlier version's donut template lacks the group tag only while its content is unchanged; a changed hole re-saves the template through templateKeys(). The automatic purge after a write names a required key as the parameter does, so a PUT with userId=1 leaves an entry a GET stored as ?user_id=1&page=2 cached (checked with a throwaway fixture), as it did before. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e564e8..87e2d546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. Deploy this version with a new `CacheVersionModule` version: entries stored by an earlier version carry no group tag, and a donut template stored by one keeps lacking it even when its page is recomposed, so a bare purge would not reach them. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag; the automatic purge after a write names the key as the parameter does (`userId`), so it does not reach entries a GET stored with the snake_case key. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. Deploy this version with a new `CacheVersionModule` version: entries stored by an earlier version carry no group tag, and a page recomposed from an earlier version's donut template can still lack it, so a bare purge would not reach their query variants. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. - A recomposed donut page kept the tags only of its holes that carry a `Surrogate-Key` header, so a hole cached by its ETag alone, such as a plain `#[Cacheable]` child, could be purged once but not again. A recomposed page now keeps every hole's tags, as the first write does. ## [1.18.0] - 2026-10-29 From d562e39d0632b084236fd15feadb59844705ce0b Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 13:04:36 +0900 Subject: [PATCH 33/39] Pin the static donut's own group tag for a custom UriTag doPutStatic() passes the injected BaseUriTag's tag to SurrogateKeys, while SurrogateKeys::addTag() derives one from a fixed new UriTag(). The two are equal under the default UriTag, so removing the argument passed the suite; with a custom UriTagInterface only the argument matches the tag purge() invalidates. The new test builds a repository with a custom UriTag (setup shared with the existing custom-tag test) and checks the page's Surrogate-Key carries the group tag in that format. --- tests/DonutMaterialDependencyTest.php | 86 ++++++++++++++++++--------- 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/tests/DonutMaterialDependencyTest.php b/tests/DonutMaterialDependencyTest.php index 007cc266..39c30491 100644 --- a/tests/DonutMaterialDependencyTest.php +++ b/tests/DonutMaterialDependencyTest.php @@ -19,6 +19,7 @@ use function array_filter; use function array_values; use function dirname; +use function explode; use function str_contains; use function str_replace; @@ -292,35 +293,7 @@ public function testASecondFullMissOfTheSamePageRendersTheFreshHoleContent(): vo */ public function testAManuallyConstructedRepositoryUsesItsOwnUriTagForTheDefaultCacheDependency(): void { - $module = new FakeEtagPoolModule(ModuleFactory::getInstance('FakeVendor\HelloWorld')); - $module->override(new TwigModule([dirname(__DIR__) . '/tests/Fake/fake-app/var/templates'])); - $injector = new Injector($module, __DIR__ . '/tmp'); - - $resource = $injector->getInstance(ResourceInterface::class); - $uriTag = new class implements UriTagInterface { - #[Override] - public function __invoke(AbstractUri $uri): string - { - return 'customtag_' . str_replace(['/', '?', '&', ':'], '_', (string) $uri); - } - - #[Override] - public function fromAssoc(string $uriTemplate, array $assoc): string - { - return ''; - } - }; - $repository = new DonutRepository( - $injector->getInstance(QueryRepositoryInterface::class), - $injector->getInstance(HeaderSetter::class), - $injector->getInstance(ResourceStorageInterface::class), - $resource, - $injector->getInstance(CdnCacheControlHeaderSetterInterface::class), - $injector->getInstance(SemanticLoggerInterface::class, CacheLog::class), - $injector->getInstance(DonutRendererInterface::class), - new BaseUriTag($uriTag), - $uriTag, - ); + [$resource, $repository] = $this->manuallyConstructedRepository(); // uri() (not newRequest(), unavailable before bear/resource 1.2x) is the same GET // request #[Embed] itself builds; run once so its headers carry the material @@ -362,6 +335,61 @@ public function testPurgeOfAHoleReachesThePageAgainAfterItWasRecomposed(): void $this->assertNotSame($recomposed, (string) $this->resource->get('page://self/html/variant-hole-parent')); } + /** With a custom UriTag, a static donut page's CDN header carries the group tag in that format, the one purge() invalidates. */ + public function testAStaticDonutPageCarriesTheGroupTagInTheCustomUriTagFormat(): void + { + [, $repository] = $this->manuallyConstructedRepository(); + $page = new class extends ResourceObject { + public function onGet(int $page = 1): static + { + return $this; + } + }; + $page->uri = new Uri('page://self/dep/unit-paged?page=2'); + $page->body = []; + + $result = $repository->putStatic($page); + + $this->assertContains('grp-customtag_page___self_dep_unit-paged', explode(' ', $result->headers[Header::SURROGATE_KEY] ?? '')); + } + + /** @return array{ResourceInterface, DonutRepository} */ + private function manuallyConstructedRepository(): array + { + $module = new FakeEtagPoolModule(ModuleFactory::getInstance('FakeVendor\HelloWorld')); + $module->override(new TwigModule([dirname(__DIR__) . '/tests/Fake/fake-app/var/templates'])); + $injector = new Injector($module, __DIR__ . '/tmp'); + $resource = $injector->getInstance(ResourceInterface::class); + $uriTag = new class implements UriTagInterface { + #[Override] + public function __invoke(AbstractUri $uri): string + { + return 'customtag_' . str_replace(['/', '?', '&', ':'], '_', (string) $uri); + } + + #[Override] + public function fromAssoc(string $uriTemplate, array $assoc): string + { + return ''; + } + }; + + return [ + $resource, + new DonutRepository( + $injector->getInstance(QueryRepositoryInterface::class), + $injector->getInstance(HeaderSetter::class), + $injector->getInstance(ResourceStorageInterface::class), + $resource, + $injector->getInstance(CdnCacheControlHeaderSetterInterface::class), + $injector->getInstance(SemanticLoggerInterface::class, CacheLog::class), + $injector->getInstance(DonutRendererInterface::class), + new BaseUriTag($uriTag), + $uriTag, + ), + ]; + } + /** * @param array $tree * From 68cd7d7412366c473ec5697268efe767cf54edd9 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 13:13:01 +0900 Subject: [PATCH 34/39] Match the custom tag exactly; narrow what a bare purge misses on old templates The custom-UriTag CacheDependency test matched a substring, which the page's own grp-customtag_... tag also satisfies since this PR, so a default CacheDependency built from new UriTag() passed it. It now matches the whole key. A page recomposed from an earlier version's donut template lacks the group tag in its CDN Surrogate-Key header and If-None-Match validator; its local view entry is still tagged by ResourceStorage::getTags() and is reached. --- CHANGELOG.md | 2 +- tests/DonutMaterialDependencyTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e2d546..42cbec45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag; the automatic purge after a write names the key as the parameter does (`userId`), so it does not reach entries a GET stored with the snake_case key. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. Deploy this version with a new `CacheVersionModule` version: entries stored by an earlier version carry no group tag, and a page recomposed from an earlier version's donut template can still lack it, so a bare purge would not reach their query variants. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag; the automatic purge after a write names the key as the parameter does (`userId`), so it does not reach entries a GET stored with the snake_case key. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. Deploy this version with a new `CacheVersionModule` version: entries stored by an earlier version carry no group tag, and a page recomposed from an earlier version's donut template can still lack it in its CDN `Surrogate-Key` header and its If-None-Match validator, so a bare purge would not reach those. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. - A recomposed donut page kept the tags only of its holes that carry a `Surrogate-Key` header, so a hole cached by its ETag alone, such as a plain `#[Cacheable]` child, could be purged once but not again. A recomposed page now keeps every hole's tags, as the first write does. ## [1.18.0] - 2026-10-29 diff --git a/tests/DonutMaterialDependencyTest.php b/tests/DonutMaterialDependencyTest.php index 39c30491..602b5264 100644 --- a/tests/DonutMaterialDependencyTest.php +++ b/tests/DonutMaterialDependencyTest.php @@ -308,7 +308,7 @@ public function testAManuallyConstructedRepositoryUsesItsOwnUriTagForTheDefaultC $result = $repository->putDonut($parent, null); - $this->assertStringContainsString('customtag_page___self_html_comment', $result->headers[Header::SURROGATE_KEY] ?? '', "the default CacheDependency must tag the child with THIS repository's own UriTag, not a bare new UriTag()"); + $this->assertContains('customtag_page___self_html_comment', explode(' ', $result->headers[Header::SURROGATE_KEY] ?? ''), "the default CacheDependency must tag the child with THIS repository's own UriTag, not a bare new UriTag()"); } /** The hole's group tag reaches the view, so a purge of the hole's bare URI recomposes the page. */ From ccd4a87cf7f28af48c2124b7bfb4155fb8a0d551 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 13:14:05 +0900 Subject: [PATCH 35/39] Match the dynamic donut's group tag as a whole token The header is '_html_blog-posting-donut_id=5 grp-_html_blog-posting-donut_'; a substring match would also accept a query-specific grp- token. --- tests/QueryVariantPurgeTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php index 5e1a2d06..cc218ff5 100644 --- a/tests/QueryVariantPurgeTest.php +++ b/tests/QueryVariantPurgeTest.php @@ -16,6 +16,7 @@ use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; use function assert; +use function explode; use function is_array; use function is_float; use function is_string; @@ -263,7 +264,7 @@ public function testDynamicDonutCarriesTheGroupTagInItsCdnHeader(): void { $ro = $this->resource->get('page://self/html/blog-posting-donut?id=5'); - $this->assertStringContainsString('grp-_html_blog-posting-donut_', $ro->headers['Surrogate-Key']); + $this->assertContains('grp-_html_blog-posting-donut_', explode(' ', $ro->headers['Surrogate-Key'])); } /** An error response invalidates its own URI's tag (e.g. a parent embedding it) but not the group tag. */ From d9a0a405a140c86ca98865606288e10dcb981e4a Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 13:20:20 +0900 Subject: [PATCH 36/39] CHANGELOG: a purge URI must spell each required key the way the GET did purge('?userId=1') leaves an entry stored by ?user_id=1&page=2 cached; purge('?user_id=1') drops it (checked against SnakeQueryParam). --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42cbec45..58afcfdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag; the automatic purge after a write names the key as the parameter does (`userId`), so it does not reach entries a GET stored with the snake_case key. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. Deploy this version with a new `CacheVersionModule` version: entries stored by an earlier version carry no group tag, and a page recomposed from an earlier version's donut template can still lack it in its CDN `Surrogate-Key` header and its If-None-Match validator, so a bare purge would not reach those. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag; the automatic purge after a write names the key as the parameter does (`userId`), so it does not reach entries a GET stored with the snake_case key. A purge URI reaches a GET's entries only when it spells each required key the way that GET did: `purge('?user_id=1')` reaches entries stored by `?user_id=1&page=2`, `purge('?userId=1')` does not. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. Deploy this version with a new `CacheVersionModule` version: entries stored by an earlier version carry no group tag, and a page recomposed from an earlier version's donut template can still lack it in its CDN `Surrogate-Key` header and its If-None-Match validator, so a bare purge would not reach those. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. - A recomposed donut page kept the tags only of its holes that carry a `Surrogate-Key` header, so a hole cached by its ETag alone, such as a plain `#[Cacheable]` child, could be purged once but not again. A recomposed page now keeps every hole's tags, as the first write does. ## [1.18.0] - 2026-10-29 From 36da94e8a9174ae480d54531601a47b09745342e Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 13:24:35 +0900 Subject: [PATCH 37/39] CHANGELOG: scope the spelling constraint to a purge aimed at the GET itself A purge of a resource the GET embeds still reaches the GET's entry through the dependency tag, whatever the spelling. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58afcfdb..3a8e2b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag; the automatic purge after a write names the key as the parameter does (`userId`), so it does not reach entries a GET stored with the snake_case key. A purge URI reaches a GET's entries only when it spells each required key the way that GET did: `purge('?user_id=1')` reaches entries stored by `?user_id=1&page=2`, `purge('?userId=1')` does not. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. Deploy this version with a new `CacheVersionModule` version: entries stored by an earlier version carry no group tag, and a page recomposed from an earlier version's donut template can still lack it in its CDN `Surrogate-Key` header and its If-None-Match validator, so a bare purge would not reach those. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag; the automatic purge after a write names the key as the parameter does (`userId`), so it does not reach entries a GET stored with the snake_case key. A purge URI aimed at a GET reaches its entries only when it spells each required key the way that GET did: `purge('?user_id=1')` reaches entries stored by `?user_id=1&page=2`, `purge('?userId=1')` does not. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. Deploy this version with a new `CacheVersionModule` version: entries stored by an earlier version carry no group tag, and a page recomposed from an earlier version's donut template can still lack it in its CDN `Surrogate-Key` header and its If-None-Match validator, so a bare purge would not reach those. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. - A recomposed donut page kept the tags only of its holes that carry a `Surrogate-Key` header, so a hole cached by its ETag alone, such as a plain `#[Cacheable]` child, could be purged once but not again. A recomposed page now keeps every hole's tags, as the first write does. ## [1.18.0] - 2026-10-29 From 94822066b928a046be78a95d0dc01c6e08ca4896 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 15:57:54 +0900 Subject: [PATCH 38/39] Lead the changelog with the deploy steps; document query-variant purges The Unreleased entry was one paragraph with the required deploy steps in the middle. It now follows 1.18.0's layout: Changed opens with the CacheVersionModule bump and the injector rebuild, then the wider automatic purge and the workaround it makes unnecessary; Fixed lists #255, #257, how a purge URI's spelling decides what it reaches, and the error path. llms-full.txt gains a "Purging query variants" section with the same rules. --- CHANGELOG.md | 11 ++++++++++- docs/llms-full.txt | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a8e2b5f..36afd469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Deploy this version with a new `CacheVersionModule` version (an app that binds its own cache adapter rather than this package's `Storage*Module`s must clear its pools instead): entries stored by an earlier version carry no group tag (see Fixed), and a page recomposed from an earlier version's donut template can still lack it in its CDN `Surrogate-Key` header and its If-None-Match validator, so a bare purge would not reach those. +- Rebuild the injector when deploying this version. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. +- The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued `onGet()` parameters from the URI it purges, so it reaches every variant even when the write request supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id and the pages that embed them. +- To reach every query variant of one resource, purging its bare URI now suffices; a shared `Surrogate-Key` added to each variant only for that purpose is no longer needed. + ### Fixed -- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so the purge reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. The automatic purge after a write does the same, so a write to `?page=1` also drops a parent that embeds `?page=2`. A required parameter requested with a snake_case key (`?user_id=1`, which BEAR.Resource binds to `$userId`) keeps that key in the group tag; the automatic purge after a write names the key as the parameter does (`userId`), so it does not reach entries a GET stored with the snake_case key. A purge URI aimed at a GET reaches its entries only when it spells each required key the way that GET did: `purge('?user_id=1')` reaches entries stored by `?user_id=1&page=2`, `purge('?userId=1')` does not. The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued parameters the same way, so it reaches every variant even when the write request itself supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id. A purge URI that spells out a default-valued parameter (e.g. `#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. Deploy this version with a new `CacheVersionModule` version: entries stored by an earlier version carry no group tag, and a page recomposed from an earlier version's donut template can still lack it in its CDN `Surrogate-Key` header and its If-None-Match validator, so a bare purge would not reach those. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so rebuild the injector when deploying this version: neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. +- A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so a bare purge of the child reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. +- What a purge URI reaches depends on how it is spelled. One that spells out a default-valued parameter (`#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A required key keeps the spelling the request used (`?user_id=1`, which BEAR.Resource binds to `$userId`), so a purge URI aimed at a GET reaches its entries only when it spells each required key the way that GET did: `purge('?user_id=1')` reaches entries stored by `?user_id=1&page=2`, `purge('?userId=1')` does not. The automatic purge after a write names the key as the parameter does (`userId`), so it does not reach entries a GET stored with the snake_case key. +- A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. - A recomposed donut page kept the tags only of its holes that carry a `Surrogate-Key` header, so a hole cached by its ETag alone, such as a plain `#[Cacheable]` child, could be purged once but not again. A recomposed page now keeps every hole's tags, as the first write does. ## [1.18.0] - 2026-10-29 diff --git a/docs/llms-full.txt b/docs/llms-full.txt index f8dbb146..afe9dbc2 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -99,6 +99,15 @@ use BEAR\RepositoryModule\Annotation\Purge; public function onDelete(int $id): static { ... } ``` +### Purging query variants + +A purge reaches every query variant of the URI it names. Each cache entry also carries a group tag: its URI with every default-valued `onGet()` parameter, and any query `onGet()` does not declare, dropped. `purge()`, `#[Purge]` and `#[Refresh]` invalidate the named URI's own tag and its group tag, so purging `app://self/articles` drops `?page=2`. A parent that embeds a variant carries the variant's group tag as well and is dropped with it. + +- A write method of a `#[Cacheable]`, `#[CacheableResponse]` or `#[DonutCache]` resource purges its own URI with default-valued parameters dropped, so it reaches every variant even when the write request supplies one. +- A purge URI that spells out a default-valued parameter (`app://self/articles?page=1`) is not coarsened and reaches only that URI's entries. +- A required key keeps the spelling the request used. A purge reaches a GET's entries only when it spells each required key the same way: `?user_id=1` reaches `?user_id=1&page=2`, `?userId=1` does not. The automatic purge after a write uses the parameter name (`userId`). +- A non-200 GET response invalidates only its own URI's tag; cached variants stay. + ## Modules ### StorageRedisDsnModule From 55fdc7dbca75734bc907cd50522f04f2e65f57ae Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 5 Nov 2026 16:09:52 +0900 Subject: [PATCH 39/39] Docs: a #[DonutCache] write purges only with #[RefreshCache]; purger gets two tags DonutCommandInterceptor binds to #[CacheableResponse] classes and to methods marked #[RefreshCache], so a #[DonutCache] write method purges nothing on its own; llms-full.txt said it did. purge() now hands the purger " grp-" in one call, where 1.18.0 always passed one key. CHANGELOG says a custom purger must split on spaces, and PurgerInterface documents the string as space-separated tags. The "shared Surrogate-Key no longer needed" note is limited to variants that differ in default-valued or undeclared parameters, server-side and for donut pages at the CDN; required-parameter variants and #[Cacheable] CDN copies still need one. llms-full.txt's opening sentence is narrowed the same way. --- CHANGELOG.md | 3 ++- docs/llms-full.txt | 4 ++-- src/PurgerInterface.php | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36afd469..7bb7ffbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deploy this version with a new `CacheVersionModule` version (an app that binds its own cache adapter rather than this package's `Storage*Module`s must clear its pools instead): entries stored by an earlier version carry no group tag (see Fixed), and a page recomposed from an earlier version's donut template can still lack it in its CDN `Surrogate-Key` header and its If-None-Match validator, so a bare purge would not reach those. - Rebuild the injector when deploying this version. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. - The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued `onGet()` parameters from the URI it purges, so it reaches every variant even when the write request supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id and the pages that embed them. -- To reach every query variant of one resource, purging its bare URI now suffices; a shared `Surrogate-Key` added to each variant only for that purpose is no longer needed. +- `purge()` now hands the `PurgerInterface` two tags in one call, the URI's own tag and its group tag, separated by a space, as `invalidateTags()` with several tags already did. A custom purger has to split the string on spaces, as the bundled Fastly purger does; one that sends the whole string as a single key matches no response's key. +- To reach the variants that differ only in default-valued parameters or in queries `onGet()` does not declare, purging the bare or required-only URI now suffices in the server-side pools and, for `#[CacheableResponse]`/`#[DonutCache]` pages, at the CDN; a shared `Surrogate-Key` added to each such variant only for that purpose is no longer needed there. Variants that differ in a required parameter, and the CDN copies of `#[Cacheable]` resources, still need one. ### Fixed - A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index afe9dbc2..65ec4ad5 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -101,9 +101,9 @@ public function onDelete(int $id): static { ... } ### Purging query variants -A purge reaches every query variant of the URI it names. Each cache entry also carries a group tag: its URI with every default-valued `onGet()` parameter, and any query `onGet()` does not declare, dropped. `purge()`, `#[Purge]` and `#[Refresh]` invalidate the named URI's own tag and its group tag, so purging `app://self/articles` drops `?page=2`. A parent that embeds a variant carries the variant's group tag as well and is dropped with it. +A purge also reaches the query variants of the URI it names that differ from it only in default-valued `onGet()` parameters or in queries `onGet()` does not declare. Each cache entry also carries a group tag: its URI with every default-valued `onGet()` parameter, and any query `onGet()` does not declare, dropped. `purge()`, `#[Purge]` and `#[Refresh]` invalidate the named URI's own tag and its group tag, so purging `app://self/articles` drops `?page=2`. A variant that differs in a required parameter is a separate group. A parent that embeds a variant carries the variant's group tag as well and is dropped with it. -- A write method of a `#[Cacheable]`, `#[CacheableResponse]` or `#[DonutCache]` resource purges its own URI with default-valued parameters dropped, so it reaches every variant even when the write request supplies one. +- A write method of a `#[Cacheable]` or `#[CacheableResponse]` resource, and one marked `#[RefreshCache]`, purges its own URI with default-valued parameters dropped, so it reaches every variant even when the write request supplies one. A write method of a `#[DonutCache]` resource purges its own URI only when it is marked `#[RefreshCache]`. - A purge URI that spells out a default-valued parameter (`app://self/articles?page=1`) is not coarsened and reaches only that URI's entries. - A required key keeps the spelling the request used. A purge reaches a GET's entries only when it spells each required key the same way: `?user_id=1` reaches `?user_id=1&page=2`, `?userId=1` does not. The automatic purge after a write uses the parameter name (`userId`). - A non-200 GET response invalidates only its own URI's tag; cached variants stay. diff --git a/src/PurgerInterface.php b/src/PurgerInterface.php index c52a657a..2d18875d 100644 --- a/src/PurgerInterface.php +++ b/src/PurgerInterface.php @@ -6,5 +6,6 @@ interface PurgerInterface { + /** @param string $tag One or more tags separated by a space */ public function __invoke(string $tag): void; }