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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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 written by earlier versions may hold per-user content, and an earlier version reading this version's donut cache shows raw `[le2:...]` placeholders during a rolling deploy.
- A `#[Cacheable]`/`#[CacheableResponse]` response whose own code sets `Cache-Control` to `private` or `no-store` is no longer stored server-side; its header is kept and CDN headers are removed. A value declared by `#[HttpCache]`/`#[NoHttpCache]` is still stored.
- Upgrading changes the Ray.Aop-woven class name - and so the ETag (`crc32($ro::class . ...)`) - for a `#[CacheableResponse]` class or method that also declares an `#[Embed]`, because it is now woven with a new interceptor (#226); a page with no `#[Embed]` keeps its stock woven class and ETag, and a `#[DonutCache]` page never carries an ETag at all, so neither is affected.
- `ResourceDonut::FORMAT` is no longer public; use `ResourceDonut::placeholder()`. `ResourceDonut::refresh()` takes an additional `UncacheableMarkInterface` argument.
- Requires `koriym/semantic-logger` `^0.11`. Written logs use its `log` document shape, and diagnostic `kind` is now `code`.
- Requires `bear/resource` `^1.22.4`.
- `KeepMutationsAndFailures` also keeps a session cut at the record limit (`semantic_logger_truncated`): a `command` past the limit leaves no other trace.

### Fixed
- `LogFileWriter`: two sessions flushed in the same microsecond no longer overwrite each other. The timestamped filename now carries a random suffix, so both survive (#221).
- A `#[DonutCache]` page embedding an `#[Embed]` hole whose URI carried two or more query parameters leaked a raw `[le:...]` placeholder to the client and could throw `ParameterException` on the next request - fixed by moving new placeholders to a `[le2:...]` marker (#232).
- An embedded child of a `#[DonutCache]`/`#[CacheableResponse]` page is now reused until its query or links change, instead of running again when rendered as a hole after already being read in `onGet()`. Every such execution that was read but never rendered as a hole now tags the stored template and the live response's own Surrogate-Key/ETag, so purging any one of them refreshes the page instead of leaving it stale (#226).
- `ResourceBodyEvaluator` no longer crashes materializing an embedded child whose `jsonSerialize()` does not answer the memoized `ResourceObject` (the shape `BEAR\Async\AsyncRequest` overrides it to): such a child is now left unmaterialized instead of asserting. Async support itself remains out of scope.

## [1.17.0] - 2026-09-17

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
],
"require": {
"php": "^8.2",
"bear/resource": "^1.16.1",
"bear/resource": "^1.22.4",
"bear/sunday": "^1.5",
"koriym/semantic-logger": "^0.11",
"psr/cache": "^1.0 || ^2.0 || ^3.0",
Expand Down
69 changes: 60 additions & 9 deletions src/DonutCacheModule.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use BEAR\RepositoryModule\Annotation\CacheableResponse;
use BEAR\RepositoryModule\Annotation\DonutCache;
use BEAR\RepositoryModule\Annotation\RefreshCache;
use BEAR\Resource\Annotation\Embed;
use Override;
use Ray\Aop\AbstractMatcher;
use Ray\Aop\MatcherInterface;
Expand Down Expand Up @@ -52,6 +53,8 @@ protected function configure(): void
$this->bind(RepositoryLoggerInterface::class)->to(RepositoryLogger::class)->in(Scope::SINGLETON);
$this->bind(PurgerInterface::class)->to(NullPurger::class);
$this->bind(UriTagInterface::class)->to(UriTag::class);
// One registry per request (see DonutChildExecutions); issue #226
$this->bind(DonutChildExecutionsInterface::class)->to(DonutChildExecutions::class)->in(Scope::SINGLETON);
$this->installAopClassModule();
$this->installAopMethodModule();
}
Expand All @@ -76,6 +79,28 @@ private function installAopClassModule(): void
self::commandMethods($this->matcher),
[DonutCommandInterceptor::class],
);

// Plain bindInterceptor, not bindPriorityInterceptor: this one must run INSIDE
// EmbedInterceptor (see EmbedOnceInterceptor's own docblock for why Ray.Aop's
// onion ordering guarantees that regardless of install order), where a priority
// interceptor never would. Also requires #[Embed]: a class with none (Page\Index
// is this shape) has nothing for EmbedOnceInterceptor to do, so it is left out of
// the pointcut set entirely rather than woven in as a no-op - keeping both the
// Ray.Aop-generated class name (and so EtagSetter's crc32 input) and the per-call
// reflection cost unaffected for every page that embeds nothing. Still a
// LogicalAndMatcher, not a bare annotatedWith(), so it stays in onionOrderMatch()'s
// leftover (install-order-independent) bucket.
$this->bindInterceptor(
$this->matcher->logicalOr(
$this->matcher->annotatedWith(CacheableResponse::class),
$this->matcher->annotatedWith(DonutCache::class),
),
$this->matcher->logicalAnd(
$this->matcher->startsWith('onGet'),
$this->matcher->annotatedWith(Embed::class),
),
[EmbedOnceInterceptor::class],
);
}

/**
Expand Down Expand Up @@ -103,15 +128,7 @@ private function installAopMethodModule(): void
// declaration already governs the method is excluded. The two sets differ because
// #[DonutCache] governs onGet only: excluding it from the write bindings too would
// leave a #[RefreshCache] write on such a class with no interceptor at all.
$readNotDeclared = $this->matcher->logicalNot(
$this->matcher->logicalOr(
$this->matcher->annotatedWith(CacheableResponse::class),
$this->matcher->logicalOr(
$this->matcher->annotatedWith(Cacheable::class),
$this->matcher->annotatedWith(DonutCache::class),
),
),
);
$readNotDeclared = $this->readNotDeclaredMatcher();
$writeNotDeclared = $this->matcher->logicalNot(
$this->matcher->logicalOr(
$this->matcher->annotatedWith(CacheableResponse::class),
Expand All @@ -127,6 +144,21 @@ private function installAopMethodModule(): void
[DonutCacheInterceptor::class],
);

// Same plain-bindInterceptor reasoning as installAopClassModule()'s own
// EmbedOnceInterceptor binding (#[Embed] required too), for a class whose
// #[CacheableResponse] sits on onGet() itself rather than on the class
// (BlogPostingCache is this fixture).
$this->bindInterceptor(
$readNotDeclared,
$this->matcher->logicalAnd(
$this->matcher->annotatedWith(CacheableResponse::class),
$this->matcher->logicalAnd(
$this->matcher->startsWith('onGet'),
$this->matcher->annotatedWith(Embed::class),
),
),
[EmbedOnceInterceptor::class],
);
$this->bindInterceptor(
$writeNotDeclared,
$this->matcher->logicalAnd(
Expand All @@ -141,4 +173,23 @@ private function installAopMethodModule(): void
[DonutCommandInterceptor::class],
);
}

/**
* Classes governed by neither #[CacheableResponse], #[Cacheable] nor #[DonutCache] at
* the class level - shared by installAopMethodModule()'s read bindings so the two stay
* in lockstep: a method-level #[CacheableResponse] read is exactly where
* EmbedOnceInterceptor must run too.
*/
private function readNotDeclaredMatcher(): AbstractMatcher
{
return $this->matcher->logicalNot(
$this->matcher->logicalOr(
$this->matcher->annotatedWith(CacheableResponse::class),
$this->matcher->logicalOr(
$this->matcher->annotatedWith(Cacheable::class),
$this->matcher->annotatedWith(DonutCache::class),
),
),
);
}
}
54 changes: 54 additions & 0 deletions src/DonutChildExecutions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace BEAR\QueryRepository;

use BEAR\Resource\ResourceObject;
use Override;
use WeakMap;

/** {@inheritDoc} */
final class DonutChildExecutions implements DonutChildExecutionsInterface
{
/** @var WeakMap<ResourceObject, list<OnceRequest>> */
private WeakMap $byPage;

public function __construct()
{
// A fresh WeakMap is empty, so its template parameters are whatever the property
// declares them as - never actually checked against `object`/`mixed`.
/** @psalm-suppress PropertyTypeCoercion */
$this->byPage = new WeakMap();
}

#[Override]
public function register(ResourceObject $page, OnceRequest $once): void
{
$existing = $this->registered($page);
$existing[] = $once;
$this->byPage[$page] = $existing;
}

#[Override]
public function take(ResourceObject $page): array
{
$registered = $this->registered($page);
unset($this->byPage[$page]);

return $registered;
}

/** @return list<OnceRequest> */
private function registered(ResourceObject $page): array
{
if (! isset($this->byPage[$page])) {
return [];
}

/** @var list<OnceRequest> $registered */
$registered = $this->byPage[$page];

return $registered;
}
}
41 changes: 41 additions & 0 deletions src/DonutChildExecutionsInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace BEAR\QueryRepository;

use BEAR\Resource\ResourceObject;

/**
* Tracks every `OnceRequest` created for one page's own `#[Embed]`s (issue #226)
*
* `EmbedOnceInterceptor` registers each wrapper here as it creates it, keyed by the page
* `ResourceObject` it was embedded into - before `onGet()` runs, so a wrapper is on record
* even if `onGet()` later `unset()`s it from `$ro->body` (or overwrites the slot with a
* plain value pulled out of it) and `ResourceDonut::create()`'s body walk can no longer find
* it. `DonutRepository` reads the list back once the page's donut template has been built -
* late enough that every wrapper's `OnceRequest::generations()` is final - to tag the
* template with whichever generations executed but were never rendered as a hole.
*
* Bound `Scope::SINGLETON`, one instance per DI container (per request in the usual
* BEAR.Sunday bootstrap) - the same lifetime `UncacheableMark` already relies on. Keyed by
* object identity in a `WeakMap`, not by URI: two sibling pages embedding the same child URI
* must not share a slot, and an entry a `#[DonutCache]` write never reaches (a non-200
* response, a cache-store failure) is simply never read back, not leaked - `WeakMap` drops it
* with the page object itself.
*/
interface DonutChildExecutionsInterface
{
/** Record that `$once` was created for one of `$page`'s own `#[Embed]`s */
public function register(ResourceObject $page, OnceRequest $once): void;

/**
* Return and forget every wrapper registered for `$page`
*
* A page read exactly once: the second call for the same `$page` returns an empty list,
* so a write path that (for whatever reason) reads twice cannot double-tag.
*
* @return list<OnceRequest>
*/
public function take(ResourceObject $page): array;
}
111 changes: 102 additions & 9 deletions src/DonutRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,20 @@
use Koriym\SemanticLogger\SemanticLoggerInterface;
use Override;

use function array_key_exists;
use function array_merge;
use function array_unique;
use function array_values;
use function explode;
use function max;

final readonly class DonutRepository implements DonutRepositoryInterface
{
/**
* One dependency per collaborator, all constructor-injected.
*
* @SuppressWarnings("PHPMD.ExcessiveParameterList")
*/
public function __construct(
private QueryRepositoryInterface $queryRepository,
private DonutHeaderSetter $donutHeaderSetter,
Expand All @@ -41,6 +48,7 @@ public function __construct(
private DonutRendererInterface $renderer,
private UnstorableGateInterface $gate,
private UncacheableMarkInterface $mark,
private DonutChildExecutionsInterface $childExecutions,
private UriTagInterface $uriTag = new UriTag(),
) {
}
Expand Down Expand Up @@ -146,8 +154,21 @@ private function doPutStatic(ResourceObject $ro, int|null $ttl, int|null $sMaxAg
$keys = new SurrogateKeys($ro->uri);
$keys->addTag($ro);
$declaredKeys = $this->getHeaderKeys($ro);
$templateKeys = $this->templateKeys($ro, $declaredKeys);
$donut = ResourceDonut::create($ro, $this->renderer, $keys, $sMaxAge, true, $this->mark)->withStorageState($ttl, $templateKeys);
// create() must run before unrenderedChildren(): it is what actually runs onGet()'s
// embeds and the page's own (string) $ro template render, so an OnceRequest's own
// generations() (issue #226) is not final until it returns.
$donut = ResourceDonut::create($ro, $this->renderer, $keys, $sMaxAge, true, $this->mark);
$unrenderedChildren = $this->unrenderedChildren($ro);
// The live response's own Surrogate-Key carries every unrendered child's tag too
// (issue #226 review): without this, purging such a child's tag invalidates the
// stored template but leaves the saved ETag/view entry - tagged from $keys alone -
// reachable, so a purge after this write still serves a cache_hit.
foreach ($unrenderedChildren as $childRo) {
$keys->addTag($childRo);
}

$templateKeys = $this->templateKeys($ro, $declaredKeys, $this->childTags($unrenderedChildren));
$donut = $donut->withStorageState($ttl, $templateKeys);
$donut->render($ro, $this->renderer);
// Checked against the still-unmutated header, before setHeaders() below runs
// headerSetter/setCdnCacheControl - the same point QueryRepository checks at (issue
Expand Down Expand Up @@ -195,8 +216,20 @@ private function doPutDonut(ResourceObject $ro, int|null $donutTtl): void
$this->logger->event(new PutDonutContext((string) $ro->uri, $donutTtl, null));
$keys = new SurrogateKeys($ro->uri);
$declaredKeys = $this->getHeaderKeys($ro);
$templateKeys = $this->templateKeys($ro, $declaredKeys);
$donut = ResourceDonut::create($ro, $this->renderer, $keys, $donutTtl, false, $this->mark)->withStorageState($donutTtl, $templateKeys);
// create() must run before unrenderedChildren(): see doPutStatic()'s own comment above.
$donut = ResourceDonut::create($ro, $this->renderer, $keys, $donutTtl, false, $this->mark);
$unrenderedChildren = $this->unrenderedChildren($ro);
// Same reasoning as doPutStatic()'s own loop: this page's live CDN Surrogate-Key
// header (set below via $keys->setSurrogateHeader()) must carry an unrendered
// child's tag too, or a CDN-level purge by that tag cannot reach this response -
// even though a #[DonutCache] page never gets a server-side ETag/view entry of its
// own (only the template, tagged via $templateKeys below, is ever saved here).
foreach ($unrenderedChildren as $childRo) {
$keys->addTag($childRo);
}

$templateKeys = $this->templateKeys($ro, $declaredKeys, $this->childTags($unrenderedChildren));
$donut = $donut->withStorageState($donutTtl, $templateKeys);
$donut->render($ro, $this->renderer);
$templateUnstorable = $this->gate->reasonExcludingHoleMarks($ro);
$responseUnstorable = $this->gate->reason($ro);
Expand All @@ -221,17 +254,77 @@ private function doPutDonut(ResourceObject $ro, int|null $donutTtl): void

/**
* The tags a donut template is stored under: its own URI tag (what `purge($uri)` invalidates -
* an untagged template is reachable by no `invalidateTags()` call, issue #185), plus whatever
* the resource declared. The children's tags are deliberately absent: a child's write must drop
* the content and leave the shell to be recomposed.
* an untagged template is reachable by no `invalidateTags()` call, issue #185), whatever the
* resource declared, plus (issue #226) whatever extra tags the caller found. The latter already
* excludes a child rendered as a hole - deliberately kept out so that child's own write drops
* only the rendered content and leaves the shell to be recomposed, not the whole template.
*
* @param list<string> $declaredKeys
* @param list<string> $extraChildTags
*
* @return list<string>
*/
private function templateKeys(ResourceObject $ro, array $declaredKeys, array $extraChildTags = []): array
{
return array_values(array_unique([($this->uriTag)($ro->uri), ...$declaredKeys, ...$extraChildTags]));
}

/**
* This page's own embedded children that executed (any generation - see
* `OnceRequest::generations()`) but were never substituted into the template as a hole
* (issue #226) - read once from whatever `EmbedOnceInterceptor` registered for $ro,
* regardless of whether a child is still reachable through $ro->body by now (onGet()
* may have unset() it, or overwritten the slot with a plain value pulled out of it).
*
* A child re-executed mid-`onGet()` (a `withQuery()`/`addQuery()`/`link*()` call)
* answers more than one generation here: every one of them ran and none but possibly
* the last could ever have become a hole, so every one but a rendered last needs its own
* tag - purging an EARLIER generation's tag must invalidate the page precisely as much
* as purging the latest's.
*
* Never runs a child to find this out: `OnceRequest::generations()` answers purely from
* each wrapper's own memo, populated only by whatever already read it.
*
* `take()` is destructive (the registry answers empty on a second call for the same
* $ro), so this reads it exactly once and the caller must reuse the result for every
* purpose - both the live response's `SurrogateKeys` and the template's own tags.
*
* @return list<ResourceObject>
*/
private function unrenderedChildren(ResourceObject $ro): array
{
$children = [];
foreach ($this->childExecutions->take($ro) as $once) {
foreach ($once->generations() as $generation) {
if ($generation['rendered']) {
continue;
}

$children[] = $generation['resourceObject'];
}
}

return $children;
}

/**
* Flatten a list of children into their own URI tags and declared Surrogate-Keys
*
* @param list<ResourceObject> $children
*
* @return list<string>
*/
private function templateKeys(ResourceObject $ro, array $declaredKeys): array
private function childTags(array $children): array
{
return array_values(array_unique([($this->uriTag)($ro->uri), ...$declaredKeys]));
$tags = [];
foreach ($children as $childRo) {
$tags[] = ($this->uriTag)($childRo->uri);
if (array_key_exists(Header::SURROGATE_KEY, $childRo->headers)) {
$tags = array_merge($tags, explode(' ', $childRo->headers[Header::SURROGATE_KEY]));
}
}

return array_values(array_unique($tags));
}

/**
Expand Down
Loading
Loading