Cache

There are only two hard things in Computer Science: cache invalidation and naming things.

– Phil Karlton

Overview

A good caching system fundamentally improves the quality of user experience and reduces resource utilization costs and environmental impact. BEAR.Sunday supports the following caching features in addition to traditional simple TTL-based caching:

  • Event-driven cache invalidation
  • Cache dependency resolution
  • Donut cache and donut hole cache
  • CDN control
  • Conditional requests

Distributed Cache Framework

A distributed caching system that follows REST constraints saves not only computational resources but also network resources. BEAR.Sunday provides a caching framework that integrates server-side caches (such as Redis and APC handled directly by PHP), shared caches (known as content delivery networks - CDNs), and client-side caches (cached by web browsers and API clients) with modern CDNs.

distributed cache

Tag-based Cache Invalidation

dependency graph 2021-10-19 21 38 02

Content caching has dependency issues. If content A depends on content B, and B depends on C, then when C is updated, not only must C’s cache and ETag be updated, but also B’s cache and ETag (which depends on C), and A’s cache and ETag (which depends on B).

BEAR.Sunday solves this problem by having each resource hold the URI of dependent resources as tags. When a resource embedded with #[Embed] is modified, the cache and ETag of the resources that hold it as a dependency are invalidated, and cache regeneration occurs for the next request.

This dependency is registered only when the resource embedded with #[Embed] is itself made cacheable, for example with #[Cacheable]. Reading a non-cacheable resource in onGet() is not a dependency: its tag does not reach the page’s entries. If that resource is also rendered as a hole, purging its URI drops the stored view, and the next request recomposes the page from the template. Give such a resource a cache attribute. A value fetched eagerly with $this->resource->get() is not tracked either.

How an embedded resource is used determines the behavior (see Donut Cache below for holes):

Need Use Effect of a resource purge
Per-user content (no shared cache) a non-cacheable resource rendered as a hole on a #[DonutCache] page that hole alone re-runs on every HIT
Shared content that changes on an event a cacheable resource rendered as a hole (never read in onGet()) the shell is recomposed from the stored template
A value the page’s own onGet() reads a cacheable resource becomes a dependency the whole stored entry is dropped and the next GET is a fresh miss

Donut Cache

donut caching

Donut caching is a partial caching technique for cache optimization. It separates content into cacheable and non-cacheable parts and combines them for output.

For example, consider content containing a non-cacheable resource like “Welcome to $name”. The non-cacheable (do-not-cache) part is combined with other cacheable parts for output.

image

In this case, since the entire content is dynamic, the whole donut is not cached. Therefore, no ETag is output either.

Donut Hole Cache

image

When the donut hole part is cacheable, it can be handled the same way as donut cache. In the example above, a weather forecast resource that changes once per hour is cached and included in the news resource.

In this case, since the donut content as a whole (news) is static, the entire content is also cached and given an ETag. This creates cache dependency. When the donut hole content is updated, the entire cached donut needs to be regenerated.

This dependency resolution happens automatically. To minimize computational resources, the donut part computation is reused. When the hole part (weather resource) is updated, the cache and ETag of the entire content are also automatically updated.

Recursive Donut

recursive donut 2021-10-19 21 27 06

The donut structure is applied recursively. For example, if A contains B and B contains C, when C is modified, A’s cache and B’s cache are reused except for the modified C part. A’s and B’s caches and ETags are regenerated, but database access for A and B content retrieval and view rendering are not performed.

The optimized partial cache structure performs content regeneration with minimal cost. Clients don’t need to know about the content cache structure.

Event-Driven Content

Traditionally, CDNs considered content requiring application logic as “dynamic” and therefore not cacheable by CDNs. However, some CDNs like Fastly and Akamai now support immediate or tag-based cache invalidation within seconds, making this thinking obsolete.

BEAR.Sunday dependency resolution works not only server-side but also on shared caches. When AOP detects changes and makes PURGE requests to shared caches, related cache invalidation occurs on shared caches just like server-side.

Conditional Requests

conditional request

Content changes are managed by AOP, and content entity tags (ETags) are automatically updated. HTTP conditional requests using ETags not only minimize computational resource usage, but responses returning only 304 Not Modified also minimize network resource usage.

Usage

For classes to be cached, use the #[DonutCache] attribute for donut cache (when embedded content is not cacheable), and #[CacheableResponse] for other cases:

use BEAR\RepositoryModule\Annotation\CacheableResponse;

#[CacheableResponse]
class BlogPosting extends ResourceObject
{
    public $headers = [
        RequestHeader::CACHE_CONTROL => CacheControl::NO_CACHE
    ];

    #[Embed(rel: "comment", src: "page://self/html/comment")]
    public function onGet(int $id = 0): static
    {
        $this->body['article'] = 'hello world';

        return $this;
    }

    public function onDelete(int $id = 0): static
    {
        return $this;
    }
}

To cache only selected methods, leave the class unannotated and annotate the methods instead. In that case, mark the methods that change the cache with #[RefreshCache]:

class Todo extends ResourceObject
{
    #[CacheableResponse]
    public function onPut(int $id = 0, string $todo): static
    {
    }

    #[RefreshCache]
    public function onDelete(int $id = 0): static
    {
    }
}

Either approach applies all the features described in the overview. Because event-driven content is assumed, the cache is invalidated by events rather than by time; the default TTL depends on the CDN module, as described below. Note that #[DonutCache] does not cache the whole content, while #[CacheableResponse] does.

What a cache attribute stores is shared with every visitor, so put per-user values in a non-cacheable resource rendered as a hole on a #[DonutCache] page. QueryRepository inspects only the response’s own Cache-Control. A per-user value that reaches the cached output by any other route is stored and served to every later visitor:

  • a value of an embedded resource read in onGet()
  • a value received through #[ResourceParam]
  • a value generated in the template (for example a Twig function)

TTL

TTL is specified with DonutRepositoryInterface::putStatic(). ttl is the cache time for non-donut holes, sMaxAge is the cache time for CDNs.

use BEAR\RepositoryModule\Annotation\CacheableResponse;

#[CacheableResponse]
class BlogPosting extends ResourceObject
{
    public function __construct(private DonutRepositoryInterface $repository)
    {}

    #[Embed(rel: "comment", src: "page://self/html/comment")]
    public function onGet(): static
    {
        // process ...
        $this->repository->putStatic($this, ttl: 10, sMaxAge: 100);

        return $this;
    }
}

Default TTL value

For event-driven content, changes to the content must be reflected in the cache immediately. The default TTL therefore depends on which CDN module is installed: it is indefinite (one year) if the CDN supports tag-based cache invalidation, and 10 seconds if it does not.

The expected cache reflection time is immediate for Fastly, a few seconds for Akamai, and 10 seconds for others.

To customize it, bind it by implementing CdnCacheControlHeaderSetterInterface with reference to CdnCacheControlHeader.

Cache invalidation

Use the methods of DonutRepositoryInterface to manually invalidate the cache. This will invalidate not only the specified cache, but also the cache of the ETag, any other resources it depends on, and the cache of the ETag on the server side and, if possible, on the CDN.

interface DonutRepositoryInterface
{
    public function purge(AbstractUri $uri): void;
    public function invalidateTags(array $tags): void;
}

Invalidate by URI

// example
$this->repository->purge(new Uri('app://self/blog/comment'));

Invalidate by tag

$this->repository->invalidateTags(['template_a', 'campaign_b']);

Tag Invalidation in CDN

In order to enable tag-based cache invalidation in CDN, you need to implement and bind PurgerInterface.

use BEAR\QueryRepository\PurgerInterface;

interface PurgerInterface
{
    public function __invoke(string $tag): void;
}

Specify dependent tags

Use the SURROGATE_KEY header to specify the key for PURGE. Use a space as a separator for multiple strings.

use BEAR\QueryRepository\Header;

class Foo
{
    public $headers = [
        Header::SURROGATE_KEY => 'template_a campaign_b'
    ];

If the cache is invalidated by template_a or campaign_b tags, Foo’s cache and Foo’s ETag will be invalidated both server-side and CDN.

Resource Dependencies

Use UriTagInterface to convert a URI into a dependency tag string.

public function __construct(private UriTagInterface $uriTag)
{}
$this->headers[Header::SURROGATE_KEY] = ($this->uriTag)(new Uri('app://self/foo'));

This cache will be invalidated both server-side and CDN when app://self/foo is modified.

Make an associative array a resource dependency

// body contents
[
    ['id' => '1', 'name' => 'a'],
    ['id' => '2', 'name' => 'b'],
]

If you want to generate a list of dependent URI tags from a body associative array like the above, you can specify the URI template with the fromAssoc() method.

$this->headers[Header::SURROGATE_KEY] = $this->uriTag->fromAssoc(
    uriTemplate: 'app://self/item{?id}',
    assoc: $this->body
);

In the above case, this cache will be invalidated for both server-side and CDN when app://self/item?id=1 and app://self/item?id=2 are changed.

Configuration

Redis Marshaller

The Redis cache adapter allows you to configure data compression and serialization methods.

A marshaller handles the serialization of PHP objects and arrays when storing them in Redis, and deserialization when retrieving them.

use BEAR\QueryRepository\StorageRedisDsnModule;

$this->install(
    new StorageRedisDsnModule(
        dsn: 'redis://localhost:6379',
        marshallingOptions: [
            'enabled' => true,
            'type' => 'deflate',      // 'default' or 'deflate'
            'use_igbinary' => true    // requires ext-igbinary
        ]
    )
);

Marshaller types:

  • default: Uses PHP’s standard serialization (enabling use_igbinary uses a more efficient binary format)
  • deflate: Compresses data before storing (uses zlib)

Use deflate if you want to reduce Redis memory usage. This is a trade-off with CPU usage.

For the dsn format (authentication, TLS, Sentinel, Cluster, etc.) and connection options, see Redis DSN.

CDN-specific Modules

If you install a module that supports a specific CDN, vendor-specific headers will be output.

$this->install(new FastlyModule());
$this->install(new AkamaiModule());

Multi-CDN

You can also configure a multi-tier CDN and set the TTL according to the role. For example, in this diagram, a multi-functional CDN is placed upstream, and a conventional CDN is placed downstream. Content invalidation is done for the upstream CDN, and the downstream CDN uses it.

multi cdn diagram

Response headers

BEAR.Sunday handles CDN cache control automatically and outputs the CDN headers. Client cache control is described in $headers of the ResourceObject depending on the content.

This section is important for security and maintenance purposes. Make sure to specify the Cache-Control in all ResourceObjects.

Do not cache

Always specify content that cannot be cached.

ResponseHeader::CACHE_CONTROL => CacheControl::NO_STORE

If a #[Cacheable]/#[CacheableResponse]/#[DonutCache] response sets its own Cache-Control to private or no-store - including a $headers property default such as CacheControl::NO_STORE - that response is not stored server-side. Its own header is kept, and the CDN-facing headers are removed. No max-age is appended to a no-store response.

To keep the server-side cache while telling the client no-store, add #[HttpCache(noStore: true)]:

use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\RepositoryModule\Annotation\HttpCache;

#[Cacheable]
#[HttpCache(noStore: true)]
class News extends ResourceObject
{
    // ...
}

Conditional requests

Check the server for content changes before using the cache. Server-side content changes will be detected and reflected.

ResponseHeader::CACHE_CONTROL => CacheControl::NO_CACHE

Specify client cache time

The client is cached on the client. This is the most efficient cache, but server-side content changes will not be reflected at the specified time.

Also, this cache is not used when the browser reloads. The cache is used when a transition is made with the <a> tag or when a URL is entered.

ResponseHeader::CACHE_CONTROL => 'max-age=60'

If response time is important to you, consider specifying SWR.

ResponseHeader::CACHE_CONTROL => 'max-age=30 stale-while-revalidate=10'

In this case, when the max-age of 30 seconds is exceeded, the old cached (stale) response will be returned for up to 10 seconds, as specified in the SWR, until a fresh response is obtained from the origin server. This means that the cache will be updated sometime between 30 and 40 seconds after the last cache update, but every request will be a response from the cache and will be fast.

RFC7234 compliant clients

To use the client cache with APIs, use an RFC7234 compliant API client.

Private cache

Specify private if you do not want to share the cache with other clients. The cache will be saved only on the client side. In this case, do not specify the cache on the server side.

ResponseHeader::CACHE_CONTROL => 'private, max-age=30'

Even if you use shared cache, you don’t need to specify public in most cases.

Cache design

APIs (or content) can be divided into two categories: Information APIs (Information APIs) and Computation APIs (Computation APIs). The Computation API is content that is difficult to reproduce and is truly dynamic, making it unsuitable for caching. The Information API, on the other hand, is an API for content that is essentially static, even if it is read from a DB and processed by PHP.

It analyzes the content in order to apply the appropriate cache.

  • Information API or Computation API?
  • Dependencies are
  • Are the comprehension relationships
  • Is the invalidation triggered by an event or TTL?
  • Is the event detectable by the application or does it need to be monitored?
  • Is the TTL predictable or unpredictable?

Consider making cache design a part of the application design process and make it a specification. It should also contribute to the safety of your project throughout its lifecycle.

Adaptive TTL

Adaptive TTL is the ability to predict the lifetime of content and correctly tell the client or CDN when it will not be updated by an event during that time. For example, when dealing with a stock API, if it is Friday night, we know that the information will not be updated until the start of trading on Monday. We calculate the number of seconds until that time, specify it as the TTL, and then specify the appropriate TTL when it is time to trade.

The client does not need to request a resource that it knows will not be updated.

#[Cacheable]

Traditional TTL caching with #[Cacheable] is also supported.

Example: cache on the server for 30 seconds, and on the client for 30 seconds. The client cache duration follows the server-side setting:

use BEAR\RepositoryModule\Annotation\Cacheable;

#[Cacheable(expirySecond: 30)]
class CachedResource extends ResourceObject
{
    // ...
}

Example: cache on both the server and the client until the specified expiry date (the date in $body['expiry_at']):

use BEAR\RepositoryModule\Annotation\Cacheable;

#[Cacheable(expiryAt: 'expiry_at')]
class CachedResource extends ResourceObject
{
    // ...
}

See the HTTP Cache page (Japanese) for more information.

Observability

BEAR.QueryRepository can record what it actually did as a typed, schema-validated log tree instead of free-text messages: a hit or a miss, what was saved and for how long, what was invalidated, and whether the CDN purger reported the purge request as successful (not whether the CDN’s edge actually propagated it). Recording is off by default. Installing DevQueryRepositoryLogModule turns it on; installing it alone does not change cache behavior.

The log can share one tree with the record of resource requests. With the wiring in Event Sourcing’s “One tree with BEAR.QueryRepository”, each cache decision (hit or miss, save, invalidation) nests inside the resource_request that caused it, so one request reads in causal order: the resources it called and what the cache decided for each. The bear-observe skill below sets this wiring up.

Both log modules require a host where one process serves one request (PHP-FPM, a CLI script). Where it can prove otherwise — a RoadRunner worker, or inside a Swoole coroutine — a module refuses to arm, says so through error_log(), and recording stays off with nothing else changed (issue #179).

Development

In an app that already installs QueryRepositoryModule, override it. install() leaves the existing NullSemanticLogger binding in place and recording stays off.

use BEAR\QueryRepository\DevQueryRepositoryLogModule;

$this->override(new DevQueryRepositoryLogModule($this->appMeta->logDir . '/query-repository'));

With the pools left at QueryRepositoryModule’s default NullAdapter, every GET is a miss and the log never shows a hit. Install StorageRedisDsnModule or similar in development too, or bind a persistent adapter (such as FilesystemAdapter) to ResourceObjectPool and EtagPool.

Run one request and its session is written to latest.json. Render it as a tree:

vendor/bin/stree var/log/{context}/query-repository/latest.json

For a standalone Injector with no QueryRepositoryModule installed elsewhere, pass one in so the core cache bindings come with it:

use BEAR\QueryRepository\QueryRepositoryModule;

$this->install(new DevQueryRepositoryLogModule(
    $logDir,
    module: new QueryRepositoryModule(),
));

Production

ProdQueryRepositoryLogModule is for forensics, not monitoring. Hit rates and capacity belong to metrics, which are cheaper and more accurate at that. Turn it on when a cache-correctness incident — stale content, a purge that did not land — needs an answer metrics cannot give. Override it the same way in an existing app:

use BEAR\QueryRepository\ProdQueryRepositoryLogModule;

$this->override(new ProdQueryRepositoryLogModule(stream: 'php://stdout', sampleRate: 100));

Mutations and failed effects (a refused save, a failed invalidation or purge) are always kept, because they have no other witness. sampleRate keeps 1 healthy (nothing went wrong) session in N as a baseline; 0 disables sampling and keeps no healthy sessions at all. To keep a different set, implement RetentionPolicyInterface and bind it after overriding the module — the writer resolves it through DI, so the app’s binding wins.

Each entry — cache_hit, cache_miss, save_value, invalidate, cdn_headers, and more — links its own JSON Schema, so a session can be validated instead of grepped.

For AI agents

An agent can do the setup and the reading above. Add the bear-observe skill that ships with BEAR.EventSourcing to the project:

composer require --dev bear/event-sourcing
mkdir -p .claude/skills && cp -r vendor/bear/event-sourcing/skills/bear-observe .claude/skills/

Then ask it to read the cache log. The agent writes an observation dev- context (DevModule, persistent pools, bin/dev.php) into the app, proves the wiring, runs one request and reads the tree. When the log disagrees with what the code declares, it reports whether the app or the library is at fault.

BEAR.Skills is also available:

/plugin marketplace add bearsunday/BEAR.Skills
/plugin install bear-skills

It provides bear-cache-gate (installs a gate in var/loop/ that reads the cache log and checks, per flow, that the save, the hit and the invalidation actually happened). Reading the log is handled by bear-observe above.

See Why the QueryRepository Log Records Everything, Reading the Log, and What the Cache Log Proves — and What It Does Not for the full event vocabulary and its declared boundaries.

Conclusion

Web content can be of the information (data) type or the computation (process) type. Although the former is essentially static, it is difficult to treat it as completely static content due to the problems of managing content changes and dependencies, so the cache was invalidated by TTL even though no content changes occurred. BEAR.Sunday’s caching framework treats information-type content as static as possible, maximizing the power of the cache.

Terminology