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
14 changes: 4 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `UncacheableMarkInterface::mark($reason)`: code that produces a per-user value (a CSRF token, etc.) marks the current response unstorable; it is then not cached (`put_skipped{reason: unstorable}`) and is sent with `Cache-Control: private, no-store` and `Pragma: no-cache` and no CDN headers (#222). A mark also skips every `#[Cacheable]`/`#[CacheableResponse]` write that happens later in the same request, including an unrelated sibling's (#234).
- `UncacheableMark` is request-scoped: a long-running worker (Swoole, RoadRunner) that reuses one container across requests must rebind it per request, as it already must for `ServerContextInterface`.
- `DevUnstorableGateModule`: `$module->override(new DevUnstorableGateModule())` turns such a skip into a thrown `Exception\UnstorableResponseDetected` during development (`install()` has no effect here).

### 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.
- `ResourceDonut::FORMAT` is no longer public; use `ResourceDonut::placeholder()`. `ResourceDonut::refresh()` takes an additional `UncacheableMarkInterface` argument.
- 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.
- `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`.
- `KeepMutationsAndFailures` also keeps a session cut at the record limit (`semantic_logger_truncated`): a `command` past the limit leaves no other trace.
- `ResourceDonut::refresh()`: a hole that throws while an already-cached `#[DonutCache]` page is recomposed (its own `onGet()` or its render) no longer lets that exception reach the caller directly from the refresh. It now degrades the same way a cache-store failure does and falls back to rendering the whole page fresh. A transient failure (the hole recovers on that fresh render) now answers 200 with the full body instead of an error - for example a corrupted legacy placeholder's `ParameterException`, previously an uncaught 500 straight from the refresh, now answers 200 on both this request and the next, since the fresh render re-stores the template with a correct `[le2:...]` placeholder. A hole that keeps throwing still fails the request - from the fresh render instead of from the refresh - exactly as the page would without the cache attribute at all.
- `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.

### 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 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 was served to every later visitor (since 1.17.0). The page's own template failure now surfaces exactly as it would for the same page without the cache attribute, instead of being swallowed into an empty page; a hole's failure now surfaces as a clean, catchable exception too - actually a better outcome than an `#[Embed]` of the same resource gets outside a `#[DonutCache]` page, where BEAR.Resource's own `AbstractRequest::__toString()` swallows it into a fatal `E_USER_ERROR` instead.
- 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.
- A degraded cache read (a down cache pool, or - see above - a hole failing while a cached page is refreshed) now actually renders its fresh-render fallback instead of returning the un-rendered result of running `onGet()` alone: previously such a request answered 200 with an unresolved `#[Embed]` hole still in its body and no view, rather than the composed page.
- An embedded child of a `#[DonutCache]`/`#[CacheableResponse]` page that `onGet()` already read into the body is no longer re-executed when the page renders it as a hole; both now come from the same single execution (#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.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@
- **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.
- **Unstorable-Response Guard**: A producer of a per-user value (a CSRF token, etc.) can mark the current response unstorable via `UncacheableMarkInterface`, so it is never written to the shared cache ([#222](https://github.com/bearsunday/BEAR.QueryRepository/issues/222)); the mark is per-request state and must be rebound each request under a long-running worker (Swoole, RoadRunner). This guard only stops *new* writes — deploy a new `CacheVersionModule` version alongside it so this release starts from a fresh cache namespace: entries written by earlier versions may hold per-user content, and an earlier version reading this version's donut cache would otherwise show raw placeholders during a rolling deploy. `CacheVersionModule` only namespaces pools built through this package's own `Storage*Module`s or a `#[CacheNamespace]`-aware adapter; an app binding its own cache adapter directly must clear those pools instead.
- **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:

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.

| 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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/schemas/context/put_skipped.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"type": "string"
},
"reason": {
"description": "why no store happened: the response already carries an ETag (etag-present), is an error response (error-code), the entry kind is not whole-content cacheable (not-cacheable: a donut page served from its template plus inner caches is never stored as a rendered page), or the response is unstorable for this request (unstorable, issue #222: a producer called UncacheableMarkInterface::mark() during this request, or its Cache-Control already contains no-store or private)",
"description": "why no store happened: the response already carries an ETag (etag-present), is an error response (error-code), the entry kind is not whole-content cacheable (not-cacheable: a donut page served from its template plus inner caches is never stored as a rendered page), or the response's own Cache-Control already contains no-store or private, outside what #[HttpCache]/#[NoHttpCache] declares (unstorable)",
"type": "string",
"enum": ["etag-present", "error-code", "not-cacheable", "unstorable"]
},
Expand Down
41 changes: 36 additions & 5 deletions src/DeclaredCacheControl.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,25 @@
use BEAR\Resource\ResourceObject;
use ReflectionClass;

use function str_contains;
use function strtolower;

/**
* The Cache-Control value `#[HttpCache]` or `#[NoHttpCache]` declares for a resource
* The Cache-Control value `#[HttpCache]`/`#[NoHttpCache]` declares for a resource, and whether
* a response's own code overrode it to something unstorable
*
* A declared value is exempt from `reason()`'s check - it is the attribute's own
* client-facing directive, not a per-request value. A runtime `private`/`no-store` outside
* that declared value is refused, since this package is a shared cache: whatever it stores
* is served to every later visitor.
*
* Mirrors `HttpCacheInterceptor`'s precedence, so this always matches what was written to
* the header. `UnstorableGateInterface::reason()` exempts a header that still reads exactly
* this value, since it's the attribute speaking, not a per-request value (#222).
* @internal
*/
final readonly class DeclaredCacheControl
{
public function __invoke(ResourceObject $ro): string|null
private const REASON = 'unstorable';

private function declared(ResourceObject $ro): string|null
{
$class = new ReflectionClass($ro);
$attributes = $class->getAttributes(HttpCache::class);
Expand All @@ -34,4 +43,26 @@ public function __invoke(ResourceObject $ro): string|null

return (string) $cacheControl;
}

/**
* The reason this response must not be written to the shared cache, or null when storable
*
* @return 'unstorable'|null
*/
public function reason(ResourceObject $ro): string|null
{
$declared = $this->declared($ro);
foreach ($ro->headers as $name => $cacheControl) {
if (strtolower($name) !== strtolower(Header::CACHE_CONTROL) || $cacheControl === $declared) {
continue;
}

$lower = strtolower($cacheControl);
if (str_contains($lower, 'no-store') || str_contains($lower, 'private')) {
return self::REASON;
}
}

return null;
}
}
43 changes: 0 additions & 43 deletions src/DevUnstorableGateModule.php

This file was deleted.

2 changes: 0 additions & 2 deletions src/DonutCacheModule.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
* The following bindings are provided:
*
* HeaderSetter
* DonutHeaderSetter
* CdnCacheControlHeaderSetterInterface
* DonutRepositoryInterface
* RepositoryLoggerInterface
Expand All @@ -43,7 +42,6 @@ final class DonutCacheModule extends AbstractModule
protected function configure(): void
{
$this->bind(HeaderSetter::class);
$this->bind(DonutHeaderSetter::class);
$this->bind(CdnCacheControlHeaderSetterInterface::class)->to(CdnCacheControlHeaderSetter::class);
$this->bind(DonutRepositoryInterface::class)->to(DonutRepository::class)->in(Scope::SINGLETON);
// Kept bound for BC; internal cache code logs through SemanticLoggerInterface instead,
Expand Down
47 changes: 0 additions & 47 deletions src/DonutHeaderSetter.php

This file was deleted.

Loading
Loading