Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
157e773
Fix #255: query-variant cache entries survive a bare-URI purge
koriym Nov 2, 2026
1fe3559
Fix PHPMD ExcessiveClassComplexity in DonutRepository (CI)
koriym Nov 2, 2026
09f8358
Mark BaseUriTagInterface/BaseUriTag @internal; shrink SurrogateKeys API
koriym Nov 2, 2026
751b4a6
Align @internal placement with MatchQueryInterface/MatchQuery precedent
koriym Nov 2, 2026
b0cae9c
Address CodeRabbit review: auto-purge coarsening, consistent default …
koriym Nov 2, 2026
d813e72
Close patch-coverage gap: cover baseQuery's required-param branch
koriym Nov 2, 2026
f3a1236
Document the auto-purge coarsening behavior change in CHANGELOG
koriym Nov 2, 2026
fe600dd
Trim diary-style comments to plain statements of fact
koriym Nov 2, 2026
5cb1b94
Drop remaining history/reviewer framing from two comments
koriym Nov 2, 2026
5361371
Fix blocker: bare-URI self-cleanup was evicting every sibling variant
koriym Nov 2, 2026
552bd7d
Update DonutCacheInterceptorTest for the group tag save_donut now rec…
koriym Nov 2, 2026
2cbc6a9
Document the bare-URI self-cleanup blocker fix in the changelog
koriym Nov 2, 2026
6ad533b
Fix second blocker: donut refresh dropped the group tag from the ETag…
koriym Nov 2, 2026
ef1cba9
Close a test-coverage gap Kimi K3.1 Pro found: initial donut save's E…
koriym Nov 2, 2026
f3af963
Close the same test-coverage gap for the dynamic #[DonutCache] flavor
koriym Nov 2, 2026
a2b80f3
Trim narrating comments to statements of fact
koriym Nov 2, 2026
9523135
State what BaseUriTag is instead of why it was added
koriym Nov 2, 2026
ea7a631
Say what the default BaseUriTag is in the constructors
koriym Nov 2, 2026
3905aef
Shorten the saveEtag() comment to a statement of fact
koriym Nov 2, 2026
a95fab6
Make BaseUriTagInterface a required constructor dependency
koriym Nov 3, 2026
85eecb0
Keep query variants cached when the bare URI answers an error
koriym Nov 3, 2026
1036de6
Pin the error path's own-tag invalidation in its test
koriym Nov 3, 2026
17da888
CHANGELOG: a purge URI that spells out a default value is not coarsened
koriym Nov 3, 2026
47eaf0f
CHANGELOG: describe the auto-purge with real write targets, not edits…
koriym Nov 3, 2026
562d82a
Mark BaseUriTagInterface @internal as BaseUriTag is
koriym Nov 5, 2026
dfafd0b
Reach a parent that embeds a query variant, and keep snake_case keys …
koriym Nov 5, 2026
3ba6b59
Reach a page that renders a query variant as a hole
koriym Nov 5, 2026
e1be240
Do not restore an injector serialized by 1.18.0
koriym Nov 5, 2026
0c189ba
Pin that a camelCase required key stays camelCase in the group tag
koriym Nov 5, 2026
b6656ac
CHANGELOG: a parent's CDN Surrogate-Key header carries the child's gr…
koriym Nov 5, 2026
b79616f
CHANGELOG: require a new CacheVersionModule version, list ResourceSto…
koriym Nov 5, 2026
ccf988b
CHANGELOG: narrow the old-template claim, state the snake_case auto-p…
koriym Nov 5, 2026
d562e39
Pin the static donut's own group tag for a custom UriTag
koriym Nov 5, 2026
68cd7d7
Match the custom tag exactly; narrow what a bare purge misses on old …
koriym Nov 5, 2026
ccd4a87
Match the dynamic donut's group tag as a whole token
koriym Nov 5, 2026
d9a0a40
CHANGELOG: a purge URI must spell each required key the way the GET did
koriym Nov 5, 2026
36da94e
CHANGELOG: scope the spelling constraint to a purge aimed at the GET …
koriym Nov 5, 2026
9482206
Lead the changelog with the deploy steps; document query-variant purges
koriym Nov 5, 2026
55fdc7d
Docs: a #[DonutCache] write purges only with #[RefreshCache]; purger …
koriym Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ 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]

### 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.
- `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()`.
- 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

### Changed
Expand Down
9 changes: 9 additions & 0 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ use BEAR\RepositoryModule\Annotation\Purge;
public function onDelete(int $id): static { ... }
```

### Purging query variants

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]` 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.

## Modules

### StorageRedisDsnModule
Expand Down
72 changes: 72 additions & 0 deletions src/BaseUriTag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace BEAR\QueryRepository;

use BEAR\Resource\ResourceObject;
use Override;
use ReflectionMethod;

use function ltrim;
use function method_exists;
use function preg_replace;
use function strtolower;

/**
* {@inheritDoc}
*
* @internal
*
* 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
{
/**
* Keeps the group tag distinct from a resource's own UriTag, which cleanUpBeforeWrite() invalidates.
*
* @see QueryRepository::GROUP_TAG_PREFIX
*/
private const GROUP_TAG_PREFIX = 'grp-';

public function __construct(private readonly UriTagInterface $uriTag = new UriTag())
{
}

#[Override]
public function __invoke(ResourceObject $ro): string
{
if (! method_exists($ro, 'onGet')) {
return self::GROUP_TAG_PREFIX . ($this->uriTag)($ro->uri);
}

$query = $ro->uri->query;
$baseQuery = [];
foreach ((new ReflectionMethod($ro, 'onGet'))->getParameters() as $parameter) {
if ($parameter->isDefaultValueAvailable()) {
continue;
}

$key = isset($query[$parameter->name]) ? $parameter->name : self::snakeName($parameter->name);
if (! isset($query[$key])) {
continue;
}

/** @psalm-suppress MixedAssignment */
$baseQuery[$key] = $query[$key];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

$baseUri = clone $ro->uri;
$baseUri->query = $baseQuery;

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)), '_');
}
}
20 changes: 20 additions & 0 deletions src/BaseUriTagInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace BEAR\QueryRepository;

use BEAR\Resource\ResourceObject;

/**
* 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
{
/** Without onGet(), the group tag is derived from the full URI. */
public function __invoke(ResourceObject $ro): string;
}
7 changes: 6 additions & 1 deletion src/CacheDependency.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
$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]);
}
Expand Down
7 changes: 6 additions & 1 deletion src/CacheInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,6 +41,8 @@
{
public function __construct(
private QueryRepositoryInterface $repository,
private ResourceStorageInterface $storage,
private UriTagInterface $uriTag,
#[CacheLog]
private SemanticLoggerInterface $logger = new NullSemanticLogger(),
) {
Expand Down Expand Up @@ -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;
}
Expand Down
24 changes: 23 additions & 1 deletion src/DonutCommandInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -80,12 +81,33 @@ public function refreshDonutAndState(ResourceObject $ro): void
{
$getQuery = ($this->matchQuery)($ro);
$delUri = clone $ro->uri;
$delUri->query = $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);
$this->refresh($getQuery, $ro);
}

/**
* @param array<string, mixed> $getQuery
*
* @return array<string, mixed>
*/
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<string, mixed> $getQuery */
private function refresh(array $getQuery, ResourceObject $ro): void
{
Expand Down
15 changes: 6 additions & 9 deletions src/DonutRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,7 @@
{
private CacheDependencyInterface $cacheDependency;

/**
* 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,
Expand All @@ -49,12 +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(),
) {
// 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]
Expand Down Expand Up @@ -146,7 +143,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);
$keys = new SurrogateKeys($ro->uri, ($this->baseUriTag)($ro));
$keys->addTag($ro);
$declaredKeys = $this->getHeaderKeys($ro);
$templateKeys = $this->templateKeys($ro, $declaredKeys);
Expand All @@ -173,7 +170,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 = 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);
Expand Down Expand Up @@ -238,7 +235,7 @@ private function classifyMaterials(ResourceObject $ro): void
*/
private function templateKeys(ResourceObject $ro, array $declaredKeys): array
{
return array_values(array_unique([($this->uriTag)($ro->uri), ...$declaredKeys]));
return array_values(array_unique([($this->uriTag)($ro->uri), ($this->baseUriTag)($ro), ...$declaredKeys]));
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/PurgerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@

interface PurgerInterface
{
/** @param string $tag One or more tags separated by a space */
public function __invoke(string $tag): void;
}
16 changes: 14 additions & 2 deletions src/QueryRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,24 @@
use function sprintf;
use function strtotime;
use function time;
use function trim;

/** @internal */
final readonly class QueryRepository implements QueryRepositoryInterface
{
/** @see BaseUriTag::GROUP_TAG_PREFIX */
private const GROUP_TAG_PREFIX = 'grp-';

public function __construct(
#[CacheLog]
private SemanticLoggerInterface $logger,
private HeaderSetter $headerSetter,
private ResourceStorageInterface $storage,
private Expiry $expiry,
private CacheDependencyInterface $cacheDependency,
private BaseUriTagInterface $baseUriTag,
private DeclaredCacheControl $declaredCacheControl = new DeclaredCacheControl(),
private UriTagInterface $uriTag = new UriTag(),
) {
}

Expand Down Expand Up @@ -102,6 +108,9 @@ 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 a tag list of its own, not getTags()'s.
$surrogateKeys = trim($surrogateKeys . ' ' . ($this->baseUriTag)($ro));

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
$this->storage->saveEtag($ro->uri, $etag, $surrogateKeys, $ttl);
}

Expand Down Expand Up @@ -194,22 +203,25 @@ public function get(AbstractUri $uri): ResourceState|null
#[Override]
public function purge(AbstractUri $uri)
{
// 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
// 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.
if ($this->logger instanceof TopLevelAwareInterface && $this->logger->isTopLevel()) {
$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);
}
}

$this->logger->event(new PurgeContext((string) $uri));

return $this->storage->deleteEtag($uri);
return $this->storage->invalidateTags($tags);
}

private function getHttpCacheAnnotation(ResourceObject $ro): HttpCache|null
Expand Down
3 changes: 2 additions & 1 deletion src/QueryRepositoryModule.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
* SemanticLoggerInterface (null by default - install Dev/ProdQueryRepositoryLogModule to record)
* MatchQueryInterface
* UriTag
* MatchQueryInterface
* BaseUriTagInterface
* DeclaredCacheControl
*
* The following module are installed.
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading