From 055856e2cff86926ca35f238878de8d034c22601 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 12:01:53 +0900 Subject: [PATCH 01/12] Do not append max-age to a Cache-Control that contains no-store (#243) --- CHANGELOG.md | 1 + src/HeaderSetter.php | 4 +++- tests/HeaderSetterTest.php | 13 +++++++++++++ tests/QueryRepositoryTest.php | 2 +- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e9b70e..b27b0296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `CacheDependency::depends()` no longer unsets the child's own `Surrogate-Key` header after copying it into the parent: a child that is both read by the parent and later rendered as a hole keeps its own key for its own independent cache entry. ### Fixed +- A cached response whose `Cache-Control` contains `no-store` (e.g. `#[HttpCache(noStore: true)]` or `#[NoHttpCache]`) no longer gets a `max-age` appended, so the client header is not a contradictory `no-store, max-age=…` (#243). - `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 exception while rendering a `#[DonutCache]`/`#[CacheableResponse]` page - a hole's own resource or the page's own template - no longer stores an empty template that is served to every later visitor; the failure now surfaces to the caller instead of being swallowed into an empty page. diff --git a/src/HeaderSetter.php b/src/HeaderSetter.php index 82c611c8..4981b1f0 100644 --- a/src/HeaderSetter.php +++ b/src/HeaderSetter.php @@ -81,7 +81,9 @@ private function setCacheControlMaxAge(ResourceObject $ro, int $age): void return; } - if (str_contains($ro->headers[$existingName], 'max-age')) { + // no-store forbids keeping the response at all, so a lifetime beside it is contradictory + $existing = strtolower($ro->headers[$existingName]); + if (str_contains($existing, 'max-age') || str_contains($existing, 'no-store')) { return; } diff --git a/tests/HeaderSetterTest.php b/tests/HeaderSetterTest.php index 1f9ec011..a46d1a6e 100644 --- a/tests/HeaderSetterTest.php +++ b/tests/HeaderSetterTest.php @@ -4,6 +4,7 @@ namespace BEAR\QueryRepository; +use FakeVendor\HelloWorld\Resource\Page\Html\CacheableResponseNoStore; use FakeVendor\HelloWorld\Resource\Page\None; use PHPUnit\Framework\TestCase; @@ -70,4 +71,16 @@ public function testSetCacheControlMaxAgeDoesNotDuplicateAnExistingLowercaseMaxA $this->assertArrayNotHasKey(Header::CACHE_CONTROL, $ro->headers, 'no second, canonically-cased header was added'); $this->assertSame('max-age=30', $ro->headers['cache-control']); } + + public function testSetCacheControlMaxAgeDoesNotAppendALifetimeToNoStore(): void + { + // A declared #[HttpCache(noStore: true)] is still stored server-side, but the client + // header must not pair no-store with a lifetime (#243). + $ro = new CacheableResponseNoStore(); + $ro->headers[Header::CACHE_CONTROL] = 'no-store'; + + ($this->headerSetter())($ro, 31536000, null); + + $this->assertSame('no-store', $ro->headers[Header::CACHE_CONTROL]); + } } diff --git a/tests/QueryRepositoryTest.php b/tests/QueryRepositoryTest.php index 4df7c5b4..558f33c6 100644 --- a/tests/QueryRepositoryTest.php +++ b/tests/QueryRepositoryTest.php @@ -317,7 +317,7 @@ public function testCacheableWithNoHttpCacheStillStores(): void // must-revalidate) - a header that equals the declared value is the attribute // speaking, not a per-request value, and must not trip the unstorable check. $ro = $this->resource->get('app://self/cacheable-no-http-cache'); - $this->assertStringContainsString('max-age=10', $ro->headers[Header::CACHE_CONTROL]); + $this->assertSame('private, no-store, no-cache, must-revalidate', $ro->headers[Header::CACHE_CONTROL], 'no lifetime is appended beside no-store (#243)'); $this->logger->flush(); $this->resource->get('app://self/cacheable-no-http-cache'); From cb7af65270d8f001395e9e64cfdd7f8b102bf011 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 12:01:53 +0900 Subject: [PATCH 02/12] Document that only a cached child becomes a data dependency (#241) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7ec18d4c..0c050ada 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,11 @@ A `#[Cacheable]`/`#[CacheableResponse]`/`#[DonutCache]` response is shared with |---|---|---| | Per-user content (no shared cache) | `#[DonutCache]` hole (non-cacheable child) | that hole alone re-runs on every HIT | | Shared content that changes on an event | `#[CacheableResponse]` hole (cacheable child, never read in `onGet()`) | the shell is recomposed from the stored template; the page's own `onGet()` does not re-run | -| A value the page's own `onGet()` reads | material (the child is tagged, whether or not it is also rendered as a hole) | the whole template is dropped; the next GET is a fresh miss | +| A value the page's own `onGet()` reads | material: a cached child (one carrying an ETag or `Surrogate-Key`) is tagged, whether or not it is also rendered as a hole | the whole template is dropped; the next GET is a fresh miss | | Data that is merely stale after a while | `#[Cacheable]` with a TTL | expires on its own, no purge needed | +A child that is not itself cached carries neither header, so reading it in `onGet()` adds no dependency: purging its URI leaves the page as stored. Give such a child a cache attribute, or tag the page with `Surrogate-Key` yourself. + A writer-side `#[Purge]`/`#[Refresh]` on the command that changes the data is the alternative to waiting on this chain. ## Documentation From e9538f3e79d021eee4cab957bce002c3f46d2b8b Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 12:02:44 +0900 Subject: [PATCH 03/12] Keep only the cache-attribute remedy for a non-cached child --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c050ada..1e462fdc 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ A `#[Cacheable]`/`#[CacheableResponse]`/`#[DonutCache]` response is shared with | A value the page's own `onGet()` reads | material: a cached child (one carrying an ETag or `Surrogate-Key`) is tagged, whether or not it is also rendered as a hole | the whole template is dropped; the next GET is a fresh miss | | Data that is merely stale after a while | `#[Cacheable]` with a TTL | expires on its own, no purge needed | -A child that is not itself cached carries neither header, so reading it in `onGet()` adds no dependency: purging its URI leaves the page as stored. Give such a child a cache attribute, or tag the page with `Surrogate-Key` yourself. +A child that is not itself cached carries neither header, so reading it in `onGet()` adds no dependency: purging its URI leaves the page as stored. Give such a child a cache attribute to make it a dependency. A writer-side `#[Purge]`/`#[Refresh]` on the command that changes the data is the alternative to waiting on this chain. From 1a81146a4ba26b5c1fcd6a8daab360366a95f729 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 12:29:32 +0900 Subject: [PATCH 04/12] Mark implementation classes that applications do not construct as @internal --- src/AbstractDonutCacheInterceptor.php | 1 + src/CacheInterceptor.php | 1 + src/CommandContextFactory.php | 2 ++ src/CommandInterceptor.php | 1 + src/CommandsProvider.php | 5 ++++- src/DonutCacheInterceptor.php | 1 + src/DonutCacheableResponseInterceptor.php | 1 + src/DonutCommandInterceptor.php | 1 + src/DonutRenderer.php | 1 + src/DonutRequest.php | 2 ++ src/EntityTags.php | 2 ++ src/HttpCacheInterceptor.php | 1 + src/Log/PoolErrorLogger.php | 2 ++ src/Log/SafeSemanticLoggerProvider.php | 1 + src/Log/Session.php | 6 +++++- src/MarshallerProvider.php | 5 ++++- src/MatchQuery.php | 1 + src/RedisDsnProvider.php | 5 ++++- src/RefreshInterceptor.php | 1 + src/RefreshSameCommand.php | 1 + src/ResourceBodyEvaluator.php | 2 ++ src/ResourceState.php | 5 ++++- src/ResourceStorageSaver.php | 1 + src/SurrogateKeys.php | 1 + 24 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/AbstractDonutCacheInterceptor.php b/src/AbstractDonutCacheInterceptor.php index 87f5113e..fe72ddd2 100644 --- a/src/AbstractDonutCacheInterceptor.php +++ b/src/AbstractDonutCacheInterceptor.php @@ -28,6 +28,7 @@ use const E_USER_WARNING; +/** @internal */ abstract class AbstractDonutCacheInterceptor implements MethodInterceptor { protected const IS_ENTIRE_CONTENT_CACHEABLE = false; diff --git a/src/CacheInterceptor.php b/src/CacheInterceptor.php index 6765d30a..72610bcc 100644 --- a/src/CacheInterceptor.php +++ b/src/CacheInterceptor.php @@ -34,6 +34,7 @@ * * @see \BEAR\RepositoryModule\Annotation\Cacheable * @see https://bearsunday.github.io/manuals/1.0/en/cache.html#cacheable + * @internal */ final readonly class CacheInterceptor implements MethodInterceptor { diff --git a/src/CommandContextFactory.php b/src/CommandContextFactory.php index 461eec31..a2966a55 100644 --- a/src/CommandContextFactory.php +++ b/src/CommandContextFactory.php @@ -14,6 +14,8 @@ * Captures the command method and its #[Refresh]/#[Purge] annotations so invalidations * that follow are recorded nested under this open node. Shared by CommandInterceptor, * DonutCommandInterceptor and RefreshInterceptor. + * + * @internal */ final class CommandContextFactory { diff --git a/src/CommandInterceptor.php b/src/CommandInterceptor.php index ec70f27b..ed062fea 100644 --- a/src/CommandInterceptor.php +++ b/src/CommandInterceptor.php @@ -25,6 +25,7 @@ * @see \BEAR\RepositoryModule\Annotation\Purge * @see \BEAR\RepositoryModule\Annotation\Refresh * @see https://bearsunday.github.io/manuals/1.0/en/cache.html#tag-based-cache-invalidation + * @internal */ final readonly class CommandInterceptor implements MethodInterceptor { diff --git a/src/CommandsProvider.php b/src/CommandsProvider.php index 33687bf1..7d7f9e83 100644 --- a/src/CommandsProvider.php +++ b/src/CommandsProvider.php @@ -7,7 +7,10 @@ use Override; use Ray\Di\ProviderInterface; -/** @implements ProviderInterface> */ +/** + * @implements ProviderInterface> + * @internal + */ final readonly class CommandsProvider implements ProviderInterface { public function __construct( diff --git a/src/DonutCacheInterceptor.php b/src/DonutCacheInterceptor.php index 484a7383..87936d6c 100644 --- a/src/DonutCacheInterceptor.php +++ b/src/DonutCacheInterceptor.php @@ -11,6 +11,7 @@ * * @see \BEAR\RepositoryModule\Annotation\DonutCache * @see https://bearsunday.github.io/manuals/1.0/en/cache.html#donut-cache + * @internal */ final class DonutCacheInterceptor extends AbstractDonutCacheInterceptor { diff --git a/src/DonutCacheableResponseInterceptor.php b/src/DonutCacheableResponseInterceptor.php index 2983591f..70009d7b 100644 --- a/src/DonutCacheableResponseInterceptor.php +++ b/src/DonutCacheableResponseInterceptor.php @@ -11,6 +11,7 @@ * * @see \BEAR\RepositoryModule\Annotation\CacheableResponse * @see https://bearsunday.github.io/manuals/1.0/en/cache.html#event-driven-content + * @internal */ final class DonutCacheableResponseInterceptor extends AbstractDonutCacheInterceptor { diff --git a/src/DonutCommandInterceptor.php b/src/DonutCommandInterceptor.php index be3f25b9..bc06f97a 100644 --- a/src/DonutCommandInterceptor.php +++ b/src/DonutCommandInterceptor.php @@ -31,6 +31,7 @@ * @see \BEAR\RepositoryModule\Annotation\CacheableResponse * @see \BEAR\RepositoryModule\Annotation\DonutCache * @see https://bearsunday.github.io/manuals/1.0/en/cache.html#event-driven-content + * @internal */ final readonly class DonutCommandInterceptor implements MethodInterceptor { diff --git a/src/DonutRenderer.php b/src/DonutRenderer.php index 13171a61..0d26124b 100644 --- a/src/DonutRenderer.php +++ b/src/DonutRenderer.php @@ -8,6 +8,7 @@ use function strtr; +/** @internal */ final class DonutRenderer implements DonutRendererInterface { /** @var array */ diff --git a/src/DonutRequest.php b/src/DonutRequest.php index 332300a2..85b0525f 100644 --- a/src/DonutRequest.php +++ b/src/DonutRequest.php @@ -14,6 +14,8 @@ /** * Runs an embedded hole while a donut template renders + * + * @internal */ final readonly class DonutRequest implements Stringable { diff --git a/src/EntityTags.php b/src/EntityTags.php index ac7bce55..932870a0 100644 --- a/src/EntityTags.php +++ b/src/EntityTags.php @@ -18,6 +18,8 @@ * half-reading a validator risks answering about content nobody asked for. A token with a * PSR-6 reserved character or `*` is dropped rather than looked up, since no setter could * have issued one. + * + * @internal */ final class EntityTags { diff --git a/src/HttpCacheInterceptor.php b/src/HttpCacheInterceptor.php index 17177c89..0988aba5 100644 --- a/src/HttpCacheInterceptor.php +++ b/src/HttpCacheInterceptor.php @@ -22,6 +22,7 @@ * @see \BEAR\RepositoryModule\Annotation\HttpCache * @see \BEAR\RepositoryModule\Annotation\NoHttpCache * @see https://bearsunday.github.io/manuals/1.0/en/cache.html + * @internal */ final class HttpCacheInterceptor implements MethodInterceptor { diff --git a/src/Log/PoolErrorLogger.php b/src/Log/PoolErrorLogger.php index 056a9a84..8a481434 100644 --- a/src/Log/PoolErrorLogger.php +++ b/src/Log/PoolErrorLogger.php @@ -23,6 +23,8 @@ * `symfony/cache` adapters do not throw: they log the backend failure and answer as if the entry * were absent. Given to the pools, this turns that report into a `pool_error` event, so a store * that is down is visible where every other cache decision already is. + * + * @internal */ final class PoolErrorLogger extends AbstractLogger { diff --git a/src/Log/SafeSemanticLoggerProvider.php b/src/Log/SafeSemanticLoggerProvider.php index 3d68f56a..25f27138 100644 --- a/src/Log/SafeSemanticLoggerProvider.php +++ b/src/Log/SafeSemanticLoggerProvider.php @@ -20,6 +20,7 @@ * and a module that turns recording on also decides where the session goes. * * @implements ProviderInterface + * @internal */ final class SafeSemanticLoggerProvider implements ProviderInterface { diff --git a/src/Log/Session.php b/src/Log/Session.php index 9951ff83..e1b8d4e4 100644 --- a/src/Log/Session.php +++ b/src/Log/Session.php @@ -7,7 +7,11 @@ use Koriym\SemanticLogger\SemanticLogger; use Koriym\SemanticLogger\SemanticLoggerInterface; -/** One request's log: its open-scope depth and the logger recording it */ +/** + * One request's log: its open-scope depth and the logger recording it + * + * @internal + */ final class Session { public int $depth = 0; diff --git a/src/MarshallerProvider.php b/src/MarshallerProvider.php index 7c8e0091..4b5c0695 100644 --- a/src/MarshallerProvider.php +++ b/src/MarshallerProvider.php @@ -11,7 +11,10 @@ use Symfony\Component\Cache\Marshaller\DeflateMarshaller; use Symfony\Component\Cache\Marshaller\MarshallerInterface; -/** @implements ProviderInterface */ +/** + * @implements ProviderInterface + * @internal + */ final readonly class MarshallerProvider implements ProviderInterface { /** @param array{enabled?: bool, type?: string, use_igbinary?: bool} $options Marshalling options */ diff --git a/src/MatchQuery.php b/src/MatchQuery.php index a7470ac1..fc8a5a06 100644 --- a/src/MatchQuery.php +++ b/src/MatchQuery.php @@ -11,6 +11,7 @@ use function sprintf; +/** @internal */ final class MatchQuery implements MatchQueryInterface { /** diff --git a/src/RedisDsnProvider.php b/src/RedisDsnProvider.php index 50a90be2..5242d8e1 100644 --- a/src/RedisDsnProvider.php +++ b/src/RedisDsnProvider.php @@ -16,7 +16,10 @@ use Relay\Relay; use Symfony\Component\Cache\Adapter\RedisAdapter; -/** @implements ProviderInterface */ +/** + * @implements ProviderInterface + * @internal + */ final readonly class RedisDsnProvider implements ProviderInterface { /** diff --git a/src/RefreshInterceptor.php b/src/RefreshInterceptor.php index c5efcbc0..147d6c53 100644 --- a/src/RefreshInterceptor.php +++ b/src/RefreshInterceptor.php @@ -27,6 +27,7 @@ * @see \BEAR\RepositoryModule\Annotation\Purge * @see \BEAR\RepositoryModule\Annotation\Refresh * @see https://bearsunday.github.io/manuals/1.0/en/cache.html#event-driven-content + * @internal */ final readonly class RefreshInterceptor implements MethodInterceptor { diff --git a/src/RefreshSameCommand.php b/src/RefreshSameCommand.php index c666adf8..cacfaa61 100644 --- a/src/RefreshSameCommand.php +++ b/src/RefreshSameCommand.php @@ -19,6 +19,7 @@ use function is_callable; use function str_starts_with; +/** @internal */ final readonly class RefreshSameCommand implements CommandInterface { public function __construct( diff --git a/src/ResourceBodyEvaluator.php b/src/ResourceBodyEvaluator.php index 4bc9ebac..04707c4f 100644 --- a/src/ResourceBodyEvaluator.php +++ b/src/ResourceBodyEvaluator.php @@ -16,6 +16,8 @@ * * A body may still hold embedded requests when it reaches the storage boundary, so they * are materialized here — as copies, and without running a child that already ran. + * + * @internal */ final class ResourceBodyEvaluator { diff --git a/src/ResourceState.php b/src/ResourceState.php index 74e248c5..b30498f1 100644 --- a/src/ResourceState.php +++ b/src/ResourceState.php @@ -9,7 +9,10 @@ use function time; -/** @psalm-suppress MissingConstructor */ +/** + * @psalm-suppress MissingConstructor + * @internal + */ final class ResourceState { /** @var int */ diff --git a/src/ResourceStorageSaver.php b/src/ResourceStorageSaver.php index ac6eddbd..db9e8702 100644 --- a/src/ResourceStorageSaver.php +++ b/src/ResourceStorageSaver.php @@ -9,6 +9,7 @@ use function assert; +/** @internal */ final class ResourceStorageSaver { /** @param list $tags */ diff --git a/src/SurrogateKeys.php b/src/SurrogateKeys.php index f9ff9a42..4916ba04 100644 --- a/src/SurrogateKeys.php +++ b/src/SurrogateKeys.php @@ -13,6 +13,7 @@ use function explode; use function implode; +/** @internal */ final class SurrogateKeys { /** @var list */ From 9b6f7e21f38c9e7ed2ef4d07f75514347714168e Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 12:29:32 +0900 Subject: [PATCH 05/12] Do not keep a session only because the logger recorded a diagnostic --- CHANGELOG.md | 2 ++ src/Log/KeepMutationsAndFailures.php | 7 +------ tests/RetentionPolicyTest.php | 13 ------------- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b27b0296..e5583433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Requires `bear/resource` `^1.22.4`. - `ResourceDonut::refresh()`: a hole that throws while an already-cached `#[DonutCache]` page is recomposed now degrades to a fresh render instead of failing the request directly from the refresh, the same way a cache-store failure already does. A hole that keeps throwing still fails the request, now from the fresh render instead of the refresh. - `CacheDependency::depends()` no longer unsets the child's own `Surrogate-Key` header after copying it into the parent: a child that is both read by the parent and later rendered as a hole keeps its own key for its own independent cache entry. +- `KeepMutationsAndFailures` no longer keeps a session only because it contains a `semantic_logger_error` diagnostic; a session is kept for a mutation, a failed effect or the sample. +- Interceptors and other implementation classes that applications do not construct are now marked `@internal`; reference the attributes, interfaces and modules instead. ### Fixed - A cached response whose `Cache-Control` contains `no-store` (e.g. `#[HttpCache(noStore: true)]` or `#[NoHttpCache]`) no longer gets a `max-age` appended, so the client header is not a contradictory `no-store, max-age=…` (#243). diff --git a/src/Log/KeepMutationsAndFailures.php b/src/Log/KeepMutationsAndFailures.php index 4c969eb4..072f9d2f 100644 --- a/src/Log/KeepMutationsAndFailures.php +++ b/src/Log/KeepMutationsAndFailures.php @@ -30,8 +30,7 @@ * decide the tags, TTLs and CDN keys every later read repeats. * - effects that did not happen: `saved: false`, `invalidate` with `roPool`/`etagPool`/`cdn` * `failed`, a `manual_*` result of `failed`, and `cache_error{operation: write}` - a store or - * an invalidation that was aborted. `semantic_logger_error` joins them because it means the - * records themselves may be incomplete. + * an invalidation that was aborted. * - a sample, when a rate is configured. * * Deliberately not kept: `cache_error{operation: read}` (availability, monitored elsewhere - @@ -176,10 +175,6 @@ private function isPoint(array $record): bool */ private function isMissingEffect(array $node, mixed $type): bool { - if ($type === 'semantic_logger_error') { - return true; // the records themselves may be incomplete - } - $context = $node['context'] ?? null; if (! is_array($context)) { return false; diff --git a/tests/RetentionPolicyTest.php b/tests/RetentionPolicyTest.php index 11314dc0..b721742d 100644 --- a/tests/RetentionPolicyTest.php +++ b/tests/RetentionPolicyTest.php @@ -172,19 +172,6 @@ public function testNoSkipReasonIsAnIncidentOfItsOwn(): void } } - public function testASessionWhoseOwnRecordsMayBeIncompleteIsKept(): void - { - // An out-of-order close is a LIFO violation: the core logger records it in-band and - // discards the close it could not match, so this session is missing a fact it should have. - // Dropping it as a healthy read would hide that the log itself is unreliable here. - $first = $this->logger->open(new GetContext('page://self/html/blog-posting')); - $second = $this->logger->open(new GetContext('page://self/html/comment')); - $this->logger->close(new CacheHitContext('view'), $first); - $this->logger->close(new CacheHitContext('view'), $second); - - $this->assertTrue($this->policy->keeps($this->logger->flush())); - } - public function testAFailedOutcomeIsFoundWhereTheRootKeepsItAsARecord(): void { // A `close` is one entry on a scope, but a root record carrying `openId` when its From e53a1e30a5530c5df82e5c26915f49aba4523cd3 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 12:37:07 +0900 Subject: [PATCH 06/12] Mark interface implementations that applications reach only through DI as @internal --- src/CacheDependency.php | 1 + src/Cdn/AkamaiCacheControlHeaderSetter.php | 1 + src/Cdn/FastlyCacheControlHeaderSetter.php | 1 + src/Cdn/FastlyCachePurger.php | 1 + src/CdnCacheControlHeaderSetter.php | 1 + src/DevEtagSetter.php | 1 + src/DonutRepository.php | 1 + src/GlobalServerContext.php | 2 ++ src/HeaderSetter.php | 1 + src/MobileEtagSetter.php | 1 + src/QueryRepository.php | 1 + src/ResourceStorage.php | 1 + 12 files changed, 13 insertions(+) diff --git a/src/CacheDependency.php b/src/CacheDependency.php index d36b2edb..c08b228f 100644 --- a/src/CacheDependency.php +++ b/src/CacheDependency.php @@ -17,6 +17,7 @@ use function implode; use function sprintf; +/** @internal */ final readonly class CacheDependency implements CacheDependencyInterface { public function __construct( diff --git a/src/Cdn/AkamaiCacheControlHeaderSetter.php b/src/Cdn/AkamaiCacheControlHeaderSetter.php index 74b865cb..9c767c7b 100644 --- a/src/Cdn/AkamaiCacheControlHeaderSetter.php +++ b/src/Cdn/AkamaiCacheControlHeaderSetter.php @@ -11,6 +11,7 @@ use function sprintf; +/** @internal */ final class AkamaiCacheControlHeaderSetter implements CdnCacheControlHeaderSetterInterface { public const CDN_CACHE_CONTROL_HEADER = 'Akamai-Cache-Control'; diff --git a/src/Cdn/FastlyCacheControlHeaderSetter.php b/src/Cdn/FastlyCacheControlHeaderSetter.php index 3bb03792..88ca7737 100644 --- a/src/Cdn/FastlyCacheControlHeaderSetter.php +++ b/src/Cdn/FastlyCacheControlHeaderSetter.php @@ -10,6 +10,7 @@ use function sprintf; +/** @internal */ final class FastlyCacheControlHeaderSetter implements CdnCacheControlHeaderSetterInterface { public const CDN_CACHE_CONTROL_HEADER = 'Surrogate-Control'; diff --git a/src/Cdn/FastlyCachePurger.php b/src/Cdn/FastlyCachePurger.php index 78473328..629ee35e 100644 --- a/src/Cdn/FastlyCachePurger.php +++ b/src/Cdn/FastlyCachePurger.php @@ -8,6 +8,7 @@ use BEAR\QueryRepository\PurgerInterface; use Override; +/** @internal */ final readonly class FastlyCachePurger implements PurgerInterface { public function __construct(private FastlyCachePurgerInterface $fastlyCachePurger) diff --git a/src/CdnCacheControlHeaderSetter.php b/src/CdnCacheControlHeaderSetter.php index ca69c68f..82b72f3f 100644 --- a/src/CdnCacheControlHeaderSetter.php +++ b/src/CdnCacheControlHeaderSetter.php @@ -12,6 +12,7 @@ /** * @see https://www.ietf.org/archive/id/draft-cdn-control-header-01.html * @see https://blog.cloudflare.com/cdn-cache-control/ + * @internal */ final class CdnCacheControlHeaderSetter implements CdnCacheControlHeaderSetterInterface { diff --git a/src/DevEtagSetter.php b/src/DevEtagSetter.php index b684e472..947ee7e6 100644 --- a/src/DevEtagSetter.php +++ b/src/DevEtagSetter.php @@ -10,6 +10,7 @@ use function gmdate; +/** @internal */ final readonly class DevEtagSetter implements EtagSetterInterface { /** diff --git a/src/DonutRepository.php b/src/DonutRepository.php index 51f3f2ea..fe15dbdb 100644 --- a/src/DonutRepository.php +++ b/src/DonutRepository.php @@ -30,6 +30,7 @@ use function explode; use function max; +/** @internal */ final readonly class DonutRepository implements DonutRepositoryInterface { private CacheDependencyInterface $cacheDependency; diff --git a/src/GlobalServerContext.php b/src/GlobalServerContext.php index d3bf59d9..502b2687 100644 --- a/src/GlobalServerContext.php +++ b/src/GlobalServerContext.php @@ -13,6 +13,8 @@ * * $_SERVER is safe to read directly there; for concurrent processing (Swoole, RoadRunner), * use a request-scoped implementation instead. + * + * @internal */ final class GlobalServerContext implements ServerContextInterface { diff --git a/src/HeaderSetter.php b/src/HeaderSetter.php index 4981b1f0..3591b9fd 100644 --- a/src/HeaderSetter.php +++ b/src/HeaderSetter.php @@ -14,6 +14,7 @@ use function str_contains; use function strtolower; +/** @internal */ final readonly class HeaderSetter { /** diff --git a/src/MobileEtagSetter.php b/src/MobileEtagSetter.php index da729168..14336b7c 100644 --- a/src/MobileEtagSetter.php +++ b/src/MobileEtagSetter.php @@ -14,6 +14,7 @@ use function serialize; use function time; +/** @internal */ final readonly class MobileEtagSetter implements EtagSetterInterface { public function __construct( diff --git a/src/QueryRepository.php b/src/QueryRepository.php index 1b4ecabe..2624688c 100644 --- a/src/QueryRepository.php +++ b/src/QueryRepository.php @@ -30,6 +30,7 @@ use function strtotime; use function time; +/** @internal */ final readonly class QueryRepository implements QueryRepositoryInterface { public function __construct( diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php index 73ec97b5..1709d0a9 100644 --- a/src/ResourceStorage.php +++ b/src/ResourceStorage.php @@ -48,6 +48,7 @@ * etagProvider: ProviderInterface, * serverContext: ServerContextInterface * } + * @internal */ final class ResourceStorage implements ResourceStorageInterface, ScopedValidatorInterface { From 2f9bf772c42b6024350fe0a9bdadbc39a427b922 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 12:52:25 +0900 Subject: [PATCH 07/12] Keep classes that appear in public signatures out of @internal --- src/Log/Session.php | 6 +----- src/ResourceBodyEvaluator.php | 2 -- src/ResourceState.php | 5 +---- src/SurrogateKeys.php | 1 - 4 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/Log/Session.php b/src/Log/Session.php index e1b8d4e4..9951ff83 100644 --- a/src/Log/Session.php +++ b/src/Log/Session.php @@ -7,11 +7,7 @@ use Koriym\SemanticLogger\SemanticLogger; use Koriym\SemanticLogger\SemanticLoggerInterface; -/** - * One request's log: its open-scope depth and the logger recording it - * - * @internal - */ +/** One request's log: its open-scope depth and the logger recording it */ final class Session { public int $depth = 0; diff --git a/src/ResourceBodyEvaluator.php b/src/ResourceBodyEvaluator.php index 04707c4f..4bc9ebac 100644 --- a/src/ResourceBodyEvaluator.php +++ b/src/ResourceBodyEvaluator.php @@ -16,8 +16,6 @@ * * A body may still hold embedded requests when it reaches the storage boundary, so they * are materialized here — as copies, and without running a child that already ran. - * - * @internal */ final class ResourceBodyEvaluator { diff --git a/src/ResourceState.php b/src/ResourceState.php index b30498f1..74e248c5 100644 --- a/src/ResourceState.php +++ b/src/ResourceState.php @@ -9,10 +9,7 @@ use function time; -/** - * @psalm-suppress MissingConstructor - * @internal - */ +/** @psalm-suppress MissingConstructor */ final class ResourceState { /** @var int */ diff --git a/src/SurrogateKeys.php b/src/SurrogateKeys.php index 4916ba04..f9ff9a42 100644 --- a/src/SurrogateKeys.php +++ b/src/SurrogateKeys.php @@ -13,7 +13,6 @@ use function explode; use function implode; -/** @internal */ final class SurrogateKeys { /** @var list */ From b5ad995bb422525aae59315a2b2ac3e7a5d40ecf Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 12:53:31 +0900 Subject: [PATCH 08/12] Check every Cache-Control key for no-store before appending max-age --- src/HeaderSetter.php | 15 +++++++++++---- tests/HeaderSetterTest.php | 13 +++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/HeaderSetter.php b/src/HeaderSetter.php index 3591b9fd..a7cc43fe 100644 --- a/src/HeaderSetter.php +++ b/src/HeaderSetter.php @@ -82,10 +82,17 @@ private function setCacheControlMaxAge(ResourceObject $ro, int $age): void return; } - // no-store forbids keeping the response at all, so a lifetime beside it is contradictory - $existing = strtolower($ro->headers[$existingName]); - if (str_contains($existing, 'max-age') || str_contains($existing, 'no-store')) { - return; + // no-store forbids keeping the response at all, so a lifetime beside it is contradictory; + // every case variant of the key is a Cache-Control the client receives + foreach ($ro->headers as $name => $value) { + if (strtolower($name) !== strtolower(Header::CACHE_CONTROL)) { + continue; + } + + $lower = strtolower($value); + if (str_contains($lower, 'max-age') || str_contains($lower, 'no-store')) { + return; + } } $ro->headers[$existingName] .= ', ' . $setMaxAge; diff --git a/tests/HeaderSetterTest.php b/tests/HeaderSetterTest.php index a46d1a6e..f8a44baa 100644 --- a/tests/HeaderSetterTest.php +++ b/tests/HeaderSetterTest.php @@ -83,4 +83,17 @@ public function testSetCacheControlMaxAgeDoesNotAppendALifetimeToNoStore(): void $this->assertSame('no-store', $ro->headers[Header::CACHE_CONTROL]); } + + public function testSetCacheControlMaxAgeChecksEveryCacheControlKeyForNoStore(): void + { + // An app-set lowercase 'cache-control' can sit beside the declared 'Cache-Control: + // no-store'; whichever key comes first, no lifetime may be added to either. + $ro = new CacheableResponseNoStore(); + $ro->headers = ['cache-control' => 'public', Header::CACHE_CONTROL => 'no-store']; + + ($this->headerSetter())($ro, 60, null); + + $this->assertSame('public', $ro->headers['cache-control']); + $this->assertSame('no-store', $ro->headers[Header::CACHE_CONTROL]); + } } From 5b5d771b1f44e59e9d652c1eb7a470f77ec96da8 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 12:54:16 +0900 Subject: [PATCH 09/12] Drop the removed retention rule and gate wording from the docs, and shorten the guard CHANGELOG entry --- CHANGELOG.md | 2 +- docs/llms-full.txt | 2 +- docs/llms.txt | 2 +- src/Log/Context/CdnHeadersContext.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5583433..0f6d8bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ 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]`/`#[DonutCache]` response whose own code sets `Cache-Control` to `private` or `no-store` is not stored server-side; this includes a `$headers` property default such as `[ResponseHeader::CACHE_CONTROL => CacheControl::NO_STORE]`. To keep server-side caching with a client-facing `no-store`, declare `#[HttpCache(noStore: true)]` instead, which is still stored. +- A `#[Cacheable]`/`#[CacheableResponse]`/`#[DonutCache]` response whose own code, including a `$headers` property default, sets `Cache-Control` to `private` or `no-store` is not stored server-side. To keep server-side caching with a client-facing `no-store`, declare `#[HttpCache(noStore: true)]` instead. - `ResourceDonut::FORMAT` is no longer public; use `ResourceDonut::placeholder()`. - 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`. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index b1b2cf5e..2af9efd5 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -205,7 +205,7 @@ A donut refresh is not marked in any response header — only in the log (`refre - Off by default (`QueryRepositoryModule`): `SemanticLoggerInterface` annotated with `#[CacheLog]` → `Koriym\SemanticLogger\NullSemanticLogger`. The qualifier is why installing this package cannot decide an application's own `SemanticLoggerInterface` binding; resolve the cache log with `getInstance(SemanticLoggerInterface::class, CacheLog::class)`. Nothing is recorded, nothing accumulates, no flush is owed — the same rule the package already applies to its cache engine (`NullAdapter`). A measured healthy read carries no information (0 failure entries out of 28-95 per session in the four demos), while a recorded session costs ~1.4 KB per request until something flushes it, so recording is something an app asks for. - Development: `new DevQueryRepositoryLogModule($appDir . '/var/log/query-repository', module: new QueryRepositoryModule())`. Every session is written whole, one file per request, plus a fixed `latest.json` — `vendor/bin/stree var/log/query-repository/latest.json` renders the request that just ran. Note the wrapping: a module's `install()` cannot replace a binding the installer already holds (`Container::merge()` keeps existing keys), which is why the log module takes the module it turns recording on for, exactly as `DevEtagModule` does. -- Production: `new ProdQueryRepositoryLogModule('php://stdout', sampleRate: 1000, module: new QueryRepositoryModule())`. The session is buffered and judged at flush (`RetentionPolicyInterface`), and the line is who else knows rather than success-vs-failure: mutations (a `command` scope, a `manual_*` call, or an `invalidate` that is not marker-preceded pre-write cleanup), effects that were intended and did not happen (`saved: false`, `invalidate` with `roPool`/`etagPool`/`cdn` `failed`, a `manual_*` result of `failed`, `cache_error{operation: write}` - a store or an invalidation abandoned - plus `semantic_logger_error`, which means the records themselves may be incomplete), and an optional sample. Healthy reads are dropped, and so are two things that look like incidents but are accounted for elsewhere: `cache_error{operation: read}` is already reported by the application's own warning channel - both interceptors `trigger_error()` before degrading, for every cause and not only the outage a pool's monitoring would see - and it repeats on every request while the incident lasts (so the separation of a degraded miss from a cold one is a development-time reading), and every `put_skipped` records a store that was never attempted because a rule forbade it - a response code the path will not cache (`#[Cacheable]` skips any non-200, a donut skips 4xx and above), a validator already set, or a page served from its template - which is a decision, stable while the code and the configuration are, rather than a runtime failure. `saved: false` can still repeat per write under a capacity problem: that is the signal, and a host that needs a ceiling decorates `LogWriterInterface`. This buys forensics, not monitoring — hit rates belong to metrics. An app that wants full recording for one request implements `RetentionPolicyInterface` and overrides the binding, since only the app knows what authorizes it. +- Production: `new ProdQueryRepositoryLogModule('php://stdout', sampleRate: 1000, module: new QueryRepositoryModule())`. The session is buffered and judged at flush (`RetentionPolicyInterface`), and the line is who else knows rather than success-vs-failure: mutations (a `command` scope, a `manual_*` call, or an `invalidate` that is not marker-preceded pre-write cleanup), effects that were intended and did not happen (`saved: false`, `invalidate` with `roPool`/`etagPool`/`cdn` `failed`, a `manual_*` result of `failed`, `cache_error{operation: write}` - a store or an invalidation abandoned), and an optional sample. Healthy reads are dropped, and so are two things that look like incidents but are accounted for elsewhere: `cache_error{operation: read}` is already reported by the application's own warning channel - both interceptors `trigger_error()` before degrading, for every cause and not only the outage a pool's monitoring would see - and it repeats on every request while the incident lasts (so the separation of a degraded miss from a cold one is a development-time reading), and every `put_skipped` records a store that was never attempted because a rule forbade it - a response code the path will not cache (`#[Cacheable]` skips any non-200, a donut skips 4xx and above), a validator already set, or a page served from its template - which is a decision, stable while the code and the configuration are, rather than a runtime failure. `saved: false` can still repeat per write under a capacity problem: that is the signal, and a host that needs a ceiling decorates `LogWriterInterface`. This buys forensics, not monitoring — hit rates belong to metrics. An app that wants full recording for one request implements `RetentionPolicyInterface` and overrides the binding, since only the app knows what authorizes it. - A written session is application-log material, not an artefact: it records resource URIs **with their query strings** (`AbstractUri::__toString()` appends them, so `page://self/reset?token=…` is written in full), the client's raw `If-None-Match`, `$_SERVER['REQUEST_URI']` on the `HttpCache` path, cache/surrogate tags, CDN header values, and `cache_error.error` - the verbatim `getMessage()` of whatever the read/write path threw, which for an HTTP CDN purger can carry the purge endpoint and its credentials. No response body is recorded by any context. `LogFileWriter` therefore creates its directory 0700 and its files 0600, and a host that must scrub decorates `LogWriterInterface` rather than waiting for a redaction option: the seam is one class (`final class Scrubbed implements LogWriterInterface { public function __construct(private LogWriterInterface $inner) {} }`). The same seam rate-limits: during a cache-pool outage every request emits `cache_error`, so retention rises from `1/sampleRate` to 100% - a decorator that caps kept sessions per interval keeps an incident from flooding the collector it exists to inform. - Destinations are `LogWriterInterface` implementations, so a session's payload stays a tree rather than a string: `LogFileWriter` (one whole session per file — what `stree` reads), `LogStreamWriter` (one JSON line per session to a stream or file), `PolicyLogWriter` (the retention decorator) and `PsrLogWriter`, which hands the tree to the app's PSR-3 logger under the `log` context key. PSR-3 is an adapter and not the interface for two reasons: it carries strings, so a `LineFormatter` would flatten the tree the schemas describe, and its severity would act as a second filter able to drop what the content-based retention policy chose to keep (the level is configuration on the adapter, never derived). - Both log modules bind `SemanticLoggerInterface` annotated with `#[CacheLog]` → `SafeSemanticLogger` in singleton scope via `SafeSemanticLoggerProvider`, with the session of the request in progress resolved through `SessionStoreInterface` (`ProcessSession`, one per process, by default). The core logger never throws (koriym/semantic-logger 0.9), so a logging failure cannot break a cache read/write; `SafeSemanticLogger` keeps open-scope depth on the `Session` the store resolves and keeps no session across the serialization boundary. diff --git a/docs/llms.txt b/docs/llms.txt index 1cf26b86..d14ab72e 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -40,7 +40,7 @@ Cache operations are logged as an open/event/close tree (Koriym.SemanticLogger) - A resource GET opens a `get` scope; embedded child GETs nest under it; the scope closes with `cache_hit` / `cache_miss` (field `layer`: `resource` / `donut` / `donut-view`). - Saves (`save_value`, `save_view`, `save_etag`, `save_donut`, `save_donut_view`), dependencies (`depends_on`) and invalidations (`invalidate`) are events inside the active scope. A write opens a `command` scope recording the method, its `#[Refresh]`/`#[Purge]` annotations and the producing interceptor (`source`). -- Recording is off by default: `QueryRepositoryModule` binds `SemanticLoggerInterface` annotated with `#[CacheLog]` → `Koriym\SemanticLogger\NullSemanticLogger`, so nothing is recorded, nothing accumulates and no flush is owed — the same rule as the null cache engine default. An app turns it on by wrapping the module (an `install()` cannot replace a binding the installer already holds): `new DevQueryRepositoryLogModule($appDir . '/var/log/query-repository', module: new QueryRepositoryModule())` writes every session as one file per request (`latest.json` is `stree`-readable), and `new ProdQueryRepositoryLogModule('php://stdout', sampleRate: 1000)` buffers each session and writes only the ones nothing else can account for: mutations, effects that were intended and did not happen (`saved: false`, `invalidate` with a `failed` target, a `manual_*` result of `failed`, `cache_error{operation: write}`, `semantic_logger_error`), and an optional sample. Deliberately dropped: `cache_error{operation: read}` (the application's warning channel already has it - both interceptors `trigger_error()` before degrading - and it repeats on every request during the incident) and every `put_skipped` (a store never attempted because a rule forbade it - a response code the path will not cache, a validator already set, a page served from its template - which is a decision, stable while the code is, not a runtime failure). Both bind `SemanticLoggerInterface` annotated with `#[CacheLog]` → `SafeSemanticLogger` in singleton scope, its session resolved through `SessionStoreInterface` (`ProcessSession`, one per process, by default - a concurrent host binds a keyed store); the core logger never throws (koriym/semantic-logger 0.9), so a logging failure never breaks a cache read/write. +- Recording is off by default: `QueryRepositoryModule` binds `SemanticLoggerInterface` annotated with `#[CacheLog]` → `Koriym\SemanticLogger\NullSemanticLogger`, so nothing is recorded, nothing accumulates and no flush is owed — the same rule as the null cache engine default. An app turns it on by wrapping the module (an `install()` cannot replace a binding the installer already holds): `new DevQueryRepositoryLogModule($appDir . '/var/log/query-repository', module: new QueryRepositoryModule())` writes every session as one file per request (`latest.json` is `stree`-readable), and `new ProdQueryRepositoryLogModule('php://stdout', sampleRate: 1000)` buffers each session and writes only the ones nothing else can account for: mutations, effects that were intended and did not happen (`saved: false`, `invalidate` with a `failed` target, a `manual_*` result of `failed`, `cache_error{operation: write}`), and an optional sample. Deliberately dropped: `cache_error{operation: read}` (the application's warning channel already has it - both interceptors `trigger_error()` before degrading - and it repeats on every request during the incident) and every `put_skipped` (a store never attempted because a rule forbade it - a response code the path will not cache, a validator already set, a page served from its template - which is a decision, stable while the code is, not a runtime failure). Both bind `SemanticLoggerInterface` annotated with `#[CacheLog]` → `SafeSemanticLogger` in singleton scope, its session resolved through `SessionStoreInterface` (`ProcessSession`, one per process, by default - a concurrent host binds a keyed store); the core logger never throws (koriym/semantic-logger 0.9), so a logging failure never breaks a cache read/write. - Flushing is owned by the log module, not the app: it binds a `LogSinkInterface` (`ShutdownFlush`) that registers a shutdown-time flush once per process, so the session survives however the request ends — after output, after a 304 early `exit()`, after an uncaught error. Under PHP-FPM and the CLI that is the request's end; `ShutdownFlush` refuses to arm and warns on a runtime it can prove is concurrent (a RoadRunner worker, or inside a Swoole coroutine), where shutdown arrives once per worker and concurrent requests would share one session (#179). Verifying the event-driven cache from logs: diff --git a/src/Log/Context/CdnHeadersContext.php b/src/Log/Context/CdnHeadersContext.php index 9d73b6ce..1eef88b8 100644 --- a/src/Log/Context/CdnHeadersContext.php +++ b/src/Log/Context/CdnHeadersContext.php @@ -16,7 +16,7 @@ * effect, not the request: the bound setter's default when the caller asked for no * sMaxAge, the resource's own value when it set one, and nothing when nothing gives * the CDN a lifetime - which is how an un-cacheable donut page is meant to stay off - * the CDN, and how a response this gate refused as unstorable (issue #222) is too: + * the CDN, and how a response whose own Cache-Control is private/no-store is too: * every CDN-facing header is stripped, so this records none. What the CDN then does * is outside this record. * From e04f98e5f8246783b7a4bd57bdec2e0a8b776f10 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 13:53:34 +0900 Subject: [PATCH 10/12] Rewrite the README around what the package is, and link the manual for how to use it --- README.ja.md | 28 ---------------------------- README.md | 52 ++++++++++++++++------------------------------------ 2 files changed, 16 insertions(+), 64 deletions(-) delete mode 100644 README.ja.md diff --git a/README.ja.md b/README.ja.md deleted file mode 100644 index b867c7a7..00000000 --- a/README.ja.md +++ /dev/null @@ -1,28 +0,0 @@ -# BEAR.QueryRepository - -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/bearsunday/BEAR.QueryRepository/badges/quality-score.png?b=1.x)](https://scrutinizer-ci.com/g/bearsunday/BEAR.QueryRepository/?branch=1.x) -[![codecov](https://codecov.io/gh/bearsunday/BEAR.QueryRepository/branch/1.x/graph/badge.svg?token=eh3c9AF4Mr)](https://codecov.io/gh/koriym/BEAR.QueryRepository) -[![Type Coverage](https://shepherd.dev/github/bearsunday/BEAR.QueryRepository/coverage.svg)](https://shepherd.dev/github/bearsunday/BEAR.QueryRepository) -[![Coding Standards](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/coding-standards.yml/badge.svg)](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/coding-standards.yml) -[![Static Analysis](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/static-analysis.yml) -[![Continuous Integration](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/continuous-integration.yml) - -**BEAR.QueryRepository** は、[CQRS](http://martinfowler.com/bliki/CQRS.html) にインスパイアされた分散キャッシュフレームワークで、BEAR.Resource アプリケーションのパフォーマンスとリソース利用効率を最適化します。読み取り(クエリ)と書き込み(コマンド)を分離することで、効率的なキャッシュ管理を実現します。 - -## 主な機能 - -- **イベント駆動型キャッシュ無効化**: データ変更時にキャッシュを自動的に無効化し、一貫性を保ちます。 -- **依存解決**: リソース間の依存関係を解決し、関連するキャッシュを自動的に更新します。 -- **ドーナッツキャッシュ**: 動的コンテンツと静的コンテンツを組み合わせた部分キャッシュを実現します。 -- **CDN統合**: FastlyやAkamaiなどのモダンなCDNと連携し、共有キャッシュを効率的に管理します。 -- **ETagを利用した条件付きリクエスト**: `304 Not Modified` レスポンスでネットワークリソースを節約します。 -- **分散キャッシュサポート**: サーバーサイドキャッシュ(例: Redis、APC)、共有キャッシュ(例: CDN)、クライアントサイドキャッシュに対応します。 - -## ドキュメント - -詳細は [BEAR.Sunday キャッシュマニュアル](http://bearsunday.github.io/manuals/1.0/ja/cache.html) をご覧ください。 - -- [ログの読み方](docs/reading-the-log.ja.md) — ログに現れる全ての語と、セッションの読み方 -- [QueryRepository ログはなぜ全てを記録するのか](docs/why-the-log-records-everything.ja.md) — 設計根拠、コスト実測、既定オフの判断 -- [このログは何を証明し、何を証明しないか](docs/what-the-log-proves.ja.md) — ログが答える問い、その答えを正直に保つ機構、そして宣言された境界 -- エージェント用スキル: [bear-cache-log](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-log/SKILL.md)(セッションのインストール・読解・公開スキーマとの照合)と [bear-cache-gate](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-gate/SKILL.md)(フローごとの保存・ヒット・無効化・コストを証明する常設オラクルの設置)——いずれも `/plugin install bear-skills` から。キャッシュに限らないリクエスト全体のトレースには [bear-observe](https://github.com/bearsunday/BEAR.EventSourcing/blob/1.x/skills/bear-observe/SKILL.md) を参照。 diff --git a/README.md b/README.md index 1e462fdc..4cb9d5af 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,27 @@ # BEAR.QueryRepository -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/bearsunday/BEAR.QueryRepository/badges/quality-score.png?b=1.x)](https://scrutinizer-ci.com/g/bearsunday/BEAR.QueryRepository/?branch=1.x) -[![codecov](https://codecov.io/gh/bearsunday/BEAR.QueryRepository/branch/1.x/graph/badge.svg?token=eh3c9AF4Mr)](https://codecov.io/gh/koriym/BEAR.QueryRepository) -[![Type Coverage](https://shepherd.dev/github/bearsunday/BEAR.QueryRepository/coverage.svg)](https://shepherd.dev/github/bearsunday/BEAR.QueryRepository) -[![Coding Standards](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/coding-standards.yml/badge.svg)](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/coding-standards.yml) -[![Static Analysis](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/static-analysis.yml) [![Continuous Integration](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/continuous-integration.yml) +[![codecov](https://codecov.io/gh/bearsunday/BEAR.QueryRepository/branch/1.x/graph/badge.svg?token=eh3c9AF4Mr)](https://codecov.io/gh/koriym/BEAR.QueryRepository) -**BEAR.QueryRepository** is a distributed caching framework for BEAR.Resource applications, inspired by [CQRS](http://martinfowler.com/bliki/CQRS.html). It segregates reads and writes into separate repositories to optimize performance and resource utilization. - -## Key Features - -- **Event-Driven Cache Invalidation**: Automatically invalidates cache when data changes, ensuring consistency. -- **Dependency Resolution**: Resolves dependencies between resources and updates related caches automatically. -- **Donut Caching**: Combines dynamic and static content for efficient partial caching. -- **CDN Integration**: Seamlessly integrates with modern CDNs (e.g., Fastly, Akamai) for shared cache management. -- **Conditional Requests with ETag Support**: Reduces network overhead by leveraging `ETag` and `304 Not Modified` responses. -- **Distributed Cache Support**: Works with server-side caches (e.g., Redis, APC), shared caches (e.g., CDNs), and client-side caches. -- **Runtime-Private Response Guard**: A `#[Cacheable]`/`#[CacheableResponse]`/`#[DonutCache]` resource whose own code sets `Cache-Control` to `private` or `no-store` at runtime is not written to the shared cache; its header is kept and CDN headers are removed. A value declared by `#[HttpCache]`/`#[NoHttpCache]` is still stored. The guard reads only the response's own header (see [Cache dependencies](#cache-dependencies)). - - Deploy this version with a new `CacheVersionModule` version: entries written by earlier versions may hold per-user content, and a rolling deploy would otherwise show raw placeholders. An app that binds its own cache adapter directly must clear those pools instead. - -## Cache dependencies - -Declare a dependency with `#[Embed]` and never `unset()` or overwrite the slot - a cached `onGet()` must not fetch dependent data with `$resource->get()`. What a child becomes depends on how the page uses it: +**The repository of the query side, not a cache: writes keep it current, rather than letting it expire.** -A `#[Cacheable]`/`#[CacheableResponse]`/`#[DonutCache]` response is shared with every visitor, so a per-user value belongs in a non-cacheable `#[DonutCache]` hole, not in the cached response itself. QR inspects only the response's own header, so a per-user value that reaches the cached output any other way - a child's value read into the body in `onGet()`, a value received through `#[ResourceParam]`, or a value generated in the template (e.g. a Twig function) - is stored and served to every later visitor. In particular, do not receive a per-user value with `#[ResourceParam]` (e.g. `app://self/login#nickname`) in a cached resource - put it in a non-cacheable `#[DonutCache]` hole instead. +A [CQRS](http://martinfowler.com/bliki/CQRS.html)-style read side for BEAR.Sunday: the result of each GET is kept as a read model, and the commands that change it refresh or purge it. A resource declares this with attributes and AOP does the rest - storing, answering conditional requests, and invalidating. By default an entry lives until a write invalidates it; a TTL is available for data that may simply go stale. -| Need | Use | Effect of a child purge | -|---|---|---| -| Per-user content (no shared cache) | `#[DonutCache]` hole (non-cacheable child) | that hole alone re-runs on every HIT | -| Shared content that changes on an event | `#[CacheableResponse]` hole (cacheable child, never read in `onGet()`) | the shell is recomposed from the stored template; the page's own `onGet()` does not re-run | -| A value the page's own `onGet()` reads | material: a cached child (one carrying an ETag or `Surrogate-Key`) is tagged, whether or not it is also rendered as a hole | the whole template is dropped; the next GET is a fresh miss | -| Data that is merely stale after a while | `#[Cacheable]` with a TTL | expires on its own, no purge needed | +- **The resource graph is the dependency graph**: a cached child taken in with `#[Embed]` becomes a dependency of the parent. +- **Writes invalidate**: a command (`onPut`, `onPatch`, ...) invalidates what it changes with `#[Purge]`, `#[Refresh]` or `#[RefreshCache]`, rather than waiting for expiry. +- **One tag crosses every layer**: the origin cache, the ETag pool behind `304 Not Modified`, and the CDN's `Surrogate-Key` are invalidated by the same tags. +- **Three uses of a child**: a per-user hole (`#[DonutCache]`), a hole rebuilt on events (`#[CacheableResponse]`), and material the page reads. +- **Verifiable**: the semantic log records each store, hit, dependency and invalidation. -A child that is not itself cached carries neither header, so reading it in `onGet()` adds no dependency: purging its URI leaves the page as stored. Give such a child a cache attribute to make it a dependency. +A cache attribute declares the response shared with every visitor: put per-user values in a non-cacheable `#[DonutCache]` hole. -A writer-side `#[Purge]`/`#[Refresh]` on the command that changes the data is the alternative to waiting on this chain. +Client, CDN and origin caches, refreshed and purged by writes ## Documentation -- [BEAR.Sunday cache manual](http://bearsunday.github.io/manuals/1.0/ja/cache.html) -- [LLM Documentation](https://bearsunday.github.io/BEAR.QueryRepository/llms-full.txt) -- [Why the QueryRepository Log Records Everything](docs/why-the-log-records-everything.md) — design rationale, the cost measurements, and why recording is off by default -- [Reading the Log](docs/reading-the-log.md) — every word the log can contain, and how to read a session -- [What the Cache Log Proves — and What It Does Not](docs/what-the-log-proves.md) — the questions the log answers, the mechanisms that keep the answers honest, and the declared boundaries -- Agent skills: [bear-cache-log](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-log/SKILL.md) (install, read and verify one session against the published schemas) and [bear-cache-gate](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-gate/SKILL.md) (install a persistent oracle that proves store/hit/invalidation/cost per flow) — both via `/plugin install bear-skills`. For broader single-request tracing beyond caching, see [bear-observe](https://github.com/bearsunday/BEAR.EventSourcing/blob/1.x/skills/bear-observe/SKILL.md). +- Cache manual: [English](https://bearsunday.github.io/manuals/1.0/en/cache.html) / [日本語](https://bearsunday.github.io/manuals/1.0/ja/cache.html) +- Reading the Log ([en](docs/reading-the-log.md) / [ja](docs/reading-the-log.ja.md)): every word the log can contain, and how to read a session +- Why the Log Records Everything ([en](docs/why-the-log-records-everything.md) / [ja](docs/why-the-log-records-everything.ja.md)): the design rationale, the cost measurements, and why recording is off by default +- What the Log Proves ([en](docs/what-the-log-proves.md) / [ja](docs/what-the-log-proves.ja.md)): the questions the log answers, and its declared boundaries +- [LLM documentation](https://bearsunday.github.io/BEAR.QueryRepository/llms-full.txt) +- Agent skills, via `/plugin install bear-skills`: [bear-cache-log](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-log/SKILL.md) and [bear-cache-gate](https://github.com/bearsunday/BEAR.Skills/blob/1.x/skills/bear-cache-gate/SKILL.md) From 347bc749473388dd186af2c8f5b0b493f7ce88c4 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 14:05:36 +0900 Subject: [PATCH 11/12] Point the codecov badge at bearsunday and limit the CDN-header comment to refused responses --- README.md | 2 +- src/Log/Context/CdnHeadersContext.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4cb9d5af..85fce73f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # BEAR.QueryRepository [![Continuous Integration](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/continuous-integration.yml) -[![codecov](https://codecov.io/gh/bearsunday/BEAR.QueryRepository/branch/1.x/graph/badge.svg?token=eh3c9AF4Mr)](https://codecov.io/gh/koriym/BEAR.QueryRepository) +[![codecov](https://codecov.io/gh/bearsunday/BEAR.QueryRepository/branch/1.x/graph/badge.svg?token=eh3c9AF4Mr)](https://codecov.io/gh/bearsunday/BEAR.QueryRepository) **The repository of the query side, not a cache: writes keep it current, rather than letting it expire.** diff --git a/src/Log/Context/CdnHeadersContext.php b/src/Log/Context/CdnHeadersContext.php index 1eef88b8..63b6469c 100644 --- a/src/Log/Context/CdnHeadersContext.php +++ b/src/Log/Context/CdnHeadersContext.php @@ -16,8 +16,9 @@ * effect, not the request: the bound setter's default when the caller asked for no * sMaxAge, the resource's own value when it set one, and nothing when nothing gives * the CDN a lifetime - which is how an un-cacheable donut page is meant to stay off - * the CDN, and how a response whose own Cache-Control is private/no-store is too: - * every CDN-facing header is stripped, so this records none. What the CDN then does + * the CDN, and how a response DeclaredCacheControl::reason() refuses (its own code set + * private/no-store) is too: every CDN-facing header is stripped, so this records none. + * A no-store declared by #[HttpCache] is not refused. What the CDN then does * is outside this record. * * `headers` holds the literal name => value pairs of every CDN-facing header this From 61151094200707775867ed29ab34515e630101a7 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 28 Oct 2026 14:09:27 +0900 Subject: [PATCH 12/12] Describe the read side as cache-aside read models kept current by writes --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 85fce73f..8e94e71d 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ [![Continuous Integration](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/bearsunday/BEAR.QueryRepository/actions/workflows/continuous-integration.yml) [![codecov](https://codecov.io/gh/bearsunday/BEAR.QueryRepository/branch/1.x/graph/badge.svg?token=eh3c9AF4Mr)](https://codecov.io/gh/bearsunday/BEAR.QueryRepository) -**The repository of the query side, not a cache: writes keep it current, rather than letting it expire.** +**The repository of the query side: a read model that writes keep current, not a cache left to expire.** -A [CQRS](http://martinfowler.com/bliki/CQRS.html)-style read side for BEAR.Sunday: the result of each GET is kept as a read model, and the commands that change it refresh or purge it. A resource declares this with attributes and AOP does the rest - storing, answering conditional requests, and invalidating. By default an entry lives until a write invalidates it; a TTL is available for data that may simply go stale. +A [CQRS](http://martinfowler.com/bliki/CQRS.html)-style read side for BEAR.Sunday: the results of cacheable GETs are kept as read models. On a miss, `onGet` builds one on demand; the commands that change it refresh or purge it. A resource declares this with attributes and AOP does the rest - storing, answering conditional requests, and invalidating. By default an entry lives until a write invalidates it; a TTL is available for data that may simply go stale. - **The resource graph is the dependency graph**: a cached child taken in with `#[Embed]` becomes a dependency of the parent. - **Writes invalidate**: a command (`onPut`, `onPatch`, ...) invalidates what it changes with `#[Purge]`, `#[Refresh]` or `#[RefreshCache]`, rather than waiting for expiry.