Cache
There are only two hard things in Computer Science: cache invalidation and naming things.
– Phil Karlton
概要
優れたキャッシュシステムは、ユーザー体験の質を本質的に向上させ、資源利用コストと環境負荷を下げます。BEAR.Sundayは従来のTTLによる単純なキャッシュに加えて、以下のキャッシュ機能をサポートしています:
- イベント駆動のキャッシュ無効化
- キャッシュの依存解決
- ドーナッツキャッシュとドーナッツの穴キャッシュ
- CDNコントロール
- 条件付きリクエスト
分散キャッシュフレームワーク
REST制約に従った分散キャッシュシステムは、計算資源だけでなくネットワーク資源も節約します。PHPが直接扱うRedisやAPCなどのサーバーサイドキャッシュ、コンテンツ配信ネットワーク(CDN)として知られる共有キャッシュ、WebブラウザやAPIクライアントでキャッシュされるクライアントサイドキャッシュ、BEAR.SundayはこれらのキャッシュとモダンCDNを統合したキャッシングフレームワークを提供します。

タグベースでのキャッシュ無効化

コンテンツキャッシュには依存性の問題があります。コンテンツAがコンテンツBに依存し、BがCに依存している場合、Cが更新されるとCのキャッシュとETagだけでなく、Cに依存するBのキャッシュとETag、Bに依存するAのキャッシュとETagも更新されなければなりません。
BEAR.Sundayはそれぞれのリソースが依存リソースのURIをタグとして保持することで、この問題を解決します。#[Embed]で埋め込まれたリソースに変更があると、そのリソースを依存として登録しているリソースのキャッシュとETagが無効化され、次のリクエストのためにキャッシュの再生成が行われます。
この依存関係が登録されるのは、#[Embed]で埋め込まれたリソース自身を#[Cacheable]などでキャッシュ対象にした場合か、そのリソースが#[DependsOn]を宣言している場合(後述)だけです。どちらでもないリソースをonGet()で読み取っても依存にはならず、そのタグはページのエントリには届きません。そのリソースを穴としても描画している場合は、URIをパージすると保存済みのビューが落ち、次のリクエストでページが保存済みのテンプレートから再構成されます。そのようなリソースには#[Cacheable]などのキャッシュアトリビュートを付けてください。$this->resource->get()で先に取得した値も依存にはなりません。
#[Embed]で埋め込まれたリソースの使われ方によって、挙動は次の3通りに分かれます(#[DonutCache]と穴については後述します):
| 埋め込まれたリソースの用途 | 使うもの | リソースをパージした時の挙動 |
|---|---|---|
| ユーザー固有のコンテンツ(共有不可) | #[DonutCache]のページで穴として描画する非キャッシュのリソース |
その穴だけがHITのたびに再実行される |
| イベントで変化する共有コンテンツ | キャッシュ対象のリソースを穴として描画する(onGet()では読まない) |
外殻(シェル)が保存済みのテンプレートから再構成される |
ページのonGet()が読み取る値 |
キャッシュ対象のリソース(依存として扱われる) | 保存済みのキャッシュ全体が破棄され、次のGETはキャッシュミスになる |
#[DependsOn]
上の依存が自動で働くのは、#[Embed]で埋め込んだリソースだけです。クエリやサービスの呼び出しなど、埋め込まずに読んだデータは依存になりません。そうしたデータは、そのデータを持つリソースのURIを#[DependsOn]で宣言してください。依存を作るためだけに#[Embed]を足さなくても、そのURIへの書き込みでこのリソースが無効化されます。
use BEAR\RepositoryModule\Annotation\DependsOn;
#[Cacheable]
class ChannelEntries extends ResourceObject
{
#[DependsOn(uri: 'app://self/taxonomy?ulid={channelId}')]
public function onGet(string $channelId): static
{
$this->body = ['entries' => $this->query->entries($channelId)];
return $this;
}
}
#[DependsOn]が足すのはタグだけです。宣言したURIを無効化するのは、そのURIのリソース自身の書き込み(#[Cacheable]のとき)か、そのURIを指定した#[Purge]/#[Refresh]です。
一覧の行のクラスにも付けられます。その場合は行のpublicプロパティがテンプレート変数になり、bodyの中で見つかった行ごとにURIが展開されます。bodyは配列とpublicプロパティだけをたどるので、コレクションやGeneratorのようなTraversableの中の行は見つかりません。
制限が2つあります。#[ResourceParam]で束縛した引数は、束縛した値ではなくリクエストのクエリから読み戻します。そのため、デフォルト値のない引数を使う宣言は失敗し、デフォルト値のある引数を使う宣言は警告なくデフォルト値で展開されます。また、展開できない宣言があると、GETはDependsOnNotExpandableで失敗し、何も保存されません。
間にキャッシュしないリソースを挟むと、上に伝わるのは#[DependsOn]で宣言されたURI(そのリソースと、その下で実行されたリクエストの宣言)だけになります。その下にあるキャッシュ対象の孫リソース自身のタグは伝わりません。孫のURIを間のリソースでも#[DependsOn]で宣言するか、間のリソースを#[Cacheable]にしてください。
bear/query-repository 1.20以降が必要です。
ドーナッツキャッシュ

ドーナッツキャッシュは、キャッシュの最適化のための部分キャッシュ技術の1つです。コンテンツをキャッシュ可能な箇所とそうでない箇所に分けて合成します。
例えば「Welcome to $name」というキャッシュできないリソースが含まれるコンテンツを考えてみてください。キャッシュできない(do-not cache)部分と、その他のキャッシュ可能な部分を合成して出力します。

この場合、コンテンツ全体としては動的なので、ドーナッツ全体はキャッシュされません。そのため、ETagも出力されません。
ドーナッツの穴キャッシュ

ドーナッツの穴部分がキャッシュ可能な場合も、ドーナッツキャッシュと同じように扱えます。上記の例では、1時間に一度変更される天気予報のリソースがキャッシュされ、ニュースリソースに含まれます。
この場合、ドーナッツ全体(ニュース)としてのコンテンツは静的なので、全体もキャッシュされ、ETagも付与されます。このとき、キャッシュの依存性が発生します。ドーナッツの穴部分のコンテンツが更新された時に、キャッシュされたドーナッツ全体も再生成される必要があります。
この依存解決は自動で行われます。計算資源を最小化するため、ドーナッツ部分の計算は再利用されます。穴の部分(天気リソース)が更新されると、全体のコンテンツのキャッシュとETagも自動で更新されます。
リカーシブ・ドーナッツ

ドーナッツ構造は再帰的に適用されます。例えば、AがBを含み、BがCを含むコンテンツの場合、Cが変更されたときに、変更されたCの部分を除いて、AのキャッシュとBのキャッシュは再利用されます。AとBのキャッシュ、ETagは再生成されますが、A、Bのコンテンツ取得のためのDBアクセスやビューのレンダリングは行われません。
最適化された構造の部分キャッシュが、最小のコストでコンテンツ再生成を行います。クライアントはコンテンツのキャッシュ構造について知る必要がありません。
イベントドリブン型コンテンツ
従来、CDNはアプリケーションロジックを必要とするコンテンツは「動的」であり、したがってCDNではキャッシュできないと考えられてきました。しかし、FastlyやAkamaiなどの一部のCDNは、即時または数秒以内でのタグベースでのキャッシュ無効化が可能になり、この考え方は過去のものとなりつつあります。
BEAR.Sundayの依存解決は、サーバーサイドだけでなく共有キャッシュでも行われます。AOPが変更を検知し、共有キャッシュにPURGEリクエストを行うことで、サーバーサイドと同じように共有キャッシュ上の関連キャッシュの無効化が行われます。
条件付きリクエスト

コンテンツの変更はAOPで管理され、コンテンツのエンティティタグ(ETag)は自動で更新されます。ETagを使ったHTTPの条件付きリクエストは計算資源の利用を最小化するだけでなく、304 Not Modifiedを返すだけの応答はネットワーク資源の利用も最小化します。
利用法
キャッシュ対象のクラスにドーナッツキャッシュの場合(埋め込みコンテンツがキャッシュ不可能な場合)は#[DonutCache]、それ以外の場合は#[CacheableResponse]とアトリビュートを付与します:
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;
}
}
キャッシュ対象メソッドを選択したい場合は、クラスにアトリビュートを指定しないで、メソッドに指定します。その場合は、キャッシュ変更メソッドに#[RefreshCache]というアトリビュートを付与します:
class Todo extends ResourceObject
{
#[CacheableResponse]
public function onPut(int $id = 0, string $todo): static
{
}
#[RefreshCache]
public function onDelete(int $id = 0): static
{
}
}
どちらかの方法でアトリビュートを付与すると、概要で紹介した全ての機能が適用されます。イベントドリブン型コンテンツを想定しているため、デフォルトではキャッシュの無効化はイベント駆動で行われます。デフォルトのTTLはインストールするCDNモジュールによって異なります(詳細は後述)。#[DonutCache]の場合はコンテンツ全体はキャッシュされず、#[CacheableResponse]の場合はされることに注意してください。
キャッシュアトリビュートによって保存される部分は、全ての訪問者で共有されます。ユーザー固有の値は、#[DonutCache]のページで穴として描画する非キャッシュのリソースに入れてください。QueryRepositoryが検査するのはレスポンス自身のCache-Controlだけです。次のような経路でユーザー固有の値が保存対象の出力に含まれると、以後、そのキャッシュを利用するすべての訪問者に配信されます。
onGet()で読み取った、埋め込まれたリソースの値#[ResourceParam]で受け取った値- テンプレートで生成した値(Twigの関数など)
TTL
TTLの指定はDonutRepositoryInterface::putStatic()で行います。ttlはドーナツの穴以外のキャッシュ時間、sMaxAgeはCDNのキャッシュ時間です:
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;
}
}
TTLの既定値
イベントドリブン型コンテンツでは、コンテンツが変更されたらキャッシュにすぐに反映されなければなりません。そのため、既定値のTTLはCDNのモジュールのインストールによって変わります。
CDNがタグベースでのキャッシュ無効化をサポートしていれば、TTLは無期限(1年間)です。サポートのない場合は10秒です。キャッシュ反映時間は、Fastlyなら即時、Akamaiなら数秒、それ以外なら10秒が期待される時間です。
カスタマイズするにはCdnCacheControlHeaderを参考にCdnCacheControlHeaderSetterInterfaceを実装して束縛します。
キャッシュ無効化
手動でキャッシュを無効化するにはDonutRepositoryInterfaceのメソッドを用います。指定されたキャッシュだけでなく、そのETag、それを依存にしている他のリソースのキャッシュとそのETagが、サーバーサイドおよび可能な場合はCDN上のキャッシュも共に無効化されます:
interface DonutRepositoryInterface
{
public function purge(AbstractUri $uri): void;
public function invalidateTags(array $tags): void;
}
URIによる無効化
// app://self/articles の onGet(int $page = 1)
$this->repository->purge(new Uri('app://self/articles')); // 一覧の全ページ(?page=1, ?page=2, …)を無効化
$this->repository->purge(new Uri('app://self/articles?page=2')); // ?page=2 だけを無効化
キャッシュの各エントリは、自身のURIのタグに加えてグループタグを持ちます。グループタグは、エントリのURIからonGet()で既定値を持つ引数と、onGet()が宣言していないクエリを除いたURIのタグです。URIを指定して無効化すると、そのURIのエントリと、グループタグがそのURIのタグであるエントリが無効化されます。app://self/articles?page=2のグループタグはapp://self/articlesのタグなので、app://self/articlesを指定すると無効化されます。グループタグは既定値を持つ引数を含まないので、app://self/articles?page=1を指定した場合は、そのURIのエントリだけが無効化されます。メソッドの実行後に指定したURIを無効化する#[Purge]、無効化して取得し直す#[Refresh]アトリビュートも同じです。無効化したURIとクエリだけが異なるURI(app://self/articles?page=2など)をキャッシュ対象の依存として埋め込んだページ(タグベースでのキャッシュ無効化)もそのグループタグを持つので、一緒に無効化されます。
#[Cacheable]、#[CacheableResponse]のリソースの書き込みメソッドと、#[RefreshCache]を付けたメソッドは、自身のURIから既定値を持つ引数を除いたURIを無効化します。そのためapp://self/articlesへの書き込みは、一覧のすべてのページを無効化します。#[DonutCache]のリソースの書き込みメソッドが自身のURIを自動で無効化するのは、#[RefreshCache]を付けた場合だけです。
必須の引数は、リクエストが使ったキーのままグループタグに入ります。onGet(int $userId, int $page = 1)を?user_id=1&page=2で取得したエントリは、?user_id=1を指定すると無効化され、?userId=1を指定しても無効化されません。書き込み後の自動の無効化は、引数名のキー(?userId=1)とそのsnake_case(?user_id=1)の両方を無効化するので、GETがどちらの綴りで取得したエントリにも届きます。ただし、必須の引数ごとに綴りが混ざったGET(?userId=1&post_id=2)には届きません。これにはbear/query-repository 1.20.1以降が必要です。
タグによる無効化
$this->repository->invalidateTags(['template_a', 'campaign_b']);
CDNでタグの無効化
CDNでタグベースでのキャッシュ無効化を有効にするためには、PurgerInterfaceを実装して束縛する必要があります:
use BEAR\QueryRepository\PurgerInterface;
interface PurgerInterface
{
public function __invoke(string $tag): void;
}
依存タグの指定
PURGE用のキーを指定するためにはSURROGATE_KEYヘッダーで指定します。複数文字列の場合はスペースをセパレータとして使用します:
use BEAR\QueryRepository\Header;
class Foo
{
public $headers = [
Header::SURROGATE_KEY => 'template_a campaign_b'
];
}
template_aまたはcampaign_bのタグによるキャッシュの無効化が行われた場合、FooのキャッシュとFooのETagはサーバーサイド、CDN共に無効になります。
リソースの依存
UriTagInterfaceを使ってURIを依存タグ文字列に変換します:
public function __construct(private UriTagInterface $uriTag)
{
}
$this->headers[Header::SURROGATE_KEY] = ($this->uriTag)(new Uri('app://self/foo'));
app://self/fooに変更があった場合、このキャッシュはサーバーサイド、CDN共に無効化されます。
連想配列をリソースの依存に
// bodyの内容
[
['id' => '1', 'name' => 'a'],
['id' => '2', 'name' => 'b'],
]
上記のようなbody連想配列から、依存するURIタグリストを生成する場合はfromAssoc()メソッドでURIテンプレートを指定します:
$this->headers[Header::SURROGATE_KEY] = $this->uriTag->fromAssoc(
uriTemplate: 'app://self/item{?id}',
assoc: $this->body
);
上記の場合、app://self/item?id=1およびapp://self/item?id=2に変更があった場合に、このキャッシュはサーバーサイド、CDN共に無効化されます。
設定
Redis Marshaller
Redisキャッシュアダプターでは、データの圧縮とシリアライズ方式を設定できます。
Marshallerは、PHPのオブジェクトや配列をRedisに保存する際のシリアライズと、取り出す際のデシリアライズを行います。
use BEAR\QueryRepository\StorageRedisDsnModule;
$this->install(
new StorageRedisDsnModule(
dsn: 'redis://localhost:6379',
marshallingOptions: [
'enabled' => true,
'type' => 'deflate', // 'default' または 'deflate'
'use_igbinary' => true // ext-igbinary が必要
]
)
);
Marshallerの種類:
default: PHPの標準シリアライズを使用(use_igbinaryを有効にするとより効率的なバイナリ形式を使用)deflate: データを圧縮してから保存(zlibを使用)
Redisのメモリ使用量を削減したい場合はdeflateを使用してください。CPU使用率とのトレードオフになります。
dsnに指定できる書式(認証、TLS、Sentinel、Cluster など)と接続オプションはRedis DSNを参照してください。
CDN特定
特定CDN対応のモジュールをインストールすると、ベンダー固有のヘッダーが出力されます:
$this->install(new FastlyModule());
$this->install(new AkamaiModule());
マルチCDN
CDNを多段構成にして、役割に応じたTTLを設定することもできます。例えば以下の図では、上流に多機能なCDNを配置して、下流にはコンベンショナルなCDNを配置しています。コンテンツの無効化などは上流のCDNに対して行い、下流のCDNはそれを利用するようにします。

レスポンスヘッダー
CDNのキャッシュコントロールについてはBEAR.Sundayが自動で行い、CDN用のヘッダーを出力します。クライアントのキャッシュコントロールはコンテンツに応じてResourceObjectの$headerに記述します。
セキュリティやメンテナンスの観点から、このセクションは重要です。全てのResourceObjectでCache-Controlを指定するようにしましょう。
キャッシュ不可
キャッシュができないコンテンツは必ず指定しましょう:
ResponseHeader::CACHE_CONTROL => CacheControl::NO_STORE
#[Cacheable]、#[CacheableResponse]、#[DonutCache]のリソースが自身でCache-Controlをprivateまたはno-storeにした場合($headersプロパティの既定値にCacheControl::NO_STOREを指定した場合も同様です)、そのレスポンスはサーバーサイドに保存されません。指定したヘッダーはそのまま残り、CDN向けのヘッダーだけが取り除かれます。no-storeのレスポンスにmax-ageが付与されることもありません。
サーバーサイドのキャッシュは維持したまま、クライアントにだけno-storeを伝えたい場合は#[HttpCache(noStore: true)]を付与します:
use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\RepositoryModule\Annotation\HttpCache;
#[Cacheable]
#[HttpCache(noStore: true)]
class News extends ResourceObject
{
// ...
}
条件付きリクエスト
サーバーにコンテンツ変更がないかを確認してから、キャッシュを利用します。サーバーサイドのコンテンツの変更は検知され反映されます:
ResponseHeader::CACHE_CONTROL => CacheControl::NO_CACHE
クライアントキャッシュ時間の指定
クライアントでキャッシュされます。最も効率的なキャッシュですが、サーバーサイドでコンテンツが変更されても指定した時間には反映されません。またブラウザのリロード動作ではこのキャッシュは利用されません。<a>タグで遷移、またはURL入力した場合にキャッシュが利用されます:
ResponseHeader::CACHE_CONTROL => 'max-age=60'
レスポンス速度を重視する場合には、SWRの指定も検討しましょう:
ResponseHeader::CACHE_CONTROL => 'max-age=30 stale-while-revalidate=10'
この場合、max-ageの30秒を超えた時にオリジンサーバーからフレッシュなレスポンス取得が完了するまで、SWRで指定された最大10秒間はそれまでの古いキャッシュ(stale)レスポンスを返します。つまりキャッシュが更新されるのは最後のキャッシュ更新から30秒から40秒間の間のいずれかになりますが、どのリクエストもキャッシュからの応答になり高速です。
RFC7234対応クライアント
APIでクライアントキャッシュを利用する場合には、RFC7234対応APIクライアントを利用します:
- iOS: NSURLCache
- Android: HttpResponseCache
- PHP: guzzle-cache-middleware
- JavaScript(Node): cacheable-request
- Go: lox/httpcache
- Ruby: faraday-http-cache
- Python: requests-cache
プライベートキャッシュ
他のクライアントと共有しない場合にはprivateを指定します。クライアントサイドのみキャッシュが保存されます。この場合、サーバーサイドでキャッシュを指定しないでください。
ResponseHeader::CACHE_CONTROL => 'private, max-age=30'
キャッシュ設計
API(またはコンテンツ)は情報API(Information API)と計算API(Computation API)の2つに分類できます。計算APIは再現が難しく真に動的でキャッシュに不適なコンテンツです。一方の情報APIはDBから読み出され、PHPで加工されたとしても本質的には静的なコンテンツのAPIです。
適切なキャッシュを適用するためにコンテンツを分析します:
- 情報APIか計算APIか
- 依存関係は何か
- 内包関係は何か
- 無効化はイベントがトリガーか、それともTTLか
- イベントはアプリケーションが検知可能か、監視が必要か
- TTLは予測可能か不可能か
キャッシュ設計をアプリケーション設計プロセスの一部として捉え、仕様に含めることも検討しましょう。ライフサイクルを通してプロジェクトの安全性にも寄与するはずです。
アダプティブTTL
コンテンツの生存期間が予測可能で、その期間にイベントによる更新が行われない場合は、それをクライアントやCDNに正しく伝えます。
例えば株価のAPIを扱う場合、現在が金曜日の夜だとすると月曜の取引開始時間までは情報更新が行われないことが分かっています。その時間までの秒数を計算してTTLとして指定し、取引時間の時には適切なTTLを指定します。クライアントは更新がないと分かっているリソースにリクエストする必要はありません。
#[Cacheable]
従来の#[Cacheable]によるTTLキャッシュもサポートされます。
例)サーバーサイドで30秒キャッシュ、クライアントでも30秒キャッシュ。サーバーサイドで指定しているので、クライアントサイドでも同じ秒数でキャッシュされます:
use BEAR\RepositoryModule\Annotation\Cacheable;
#[Cacheable(expirySecond: 30)]
class CachedResource extends ResourceObject
{
// ...
}
例)指定した有効期限($body['expiry_at']の日付)まで、サーバー、クライアント共にキャッシュ:
use BEAR\RepositoryModule\Annotation\Cacheable;
#[Cacheable(expiryAt: 'expiry_at')]
class CachedResource extends ResourceObject
{
// ...
}
その他はHTTPキャッシュページをご覧ください。
可観測性(オブザーバビリティ)
BEAR.QueryRepositoryは実際の挙動を記録できます。記録されるのは、hit/miss、保存した内容とその期間、無効化した内容、そしてCDNのpurgerがpurge要求を成功と報告したかどうかです。purge要求の成功は、CDNのエッジへの実際の伝播を意味しません。ログは自由記述のメッセージではなく、型付きでスキーマ検証可能なツリーです。記録は既定でオフです。DevQueryRepositoryLogModuleをインストールすると有効になります。インストールだけではキャッシュの挙動自体は変わりません。
このログはリソースリクエストの記録と1本の木にできます。Event Sourcingの「BEAR.QueryRepository と 1 本の木」の構成にすると、キャッシュの判断(hit/miss、保存、無効化)が、それを起こしたresource_requestの中にネストします。1リクエストで何が起きたかを、リソースのネストとキャッシュの判断を合わせて起きた順に読めます。下のbear-observeはこの構成を設置します。
どちらのログモジュールも、1プロセスが1リクエストを処理するホスト(PHP-FPM、CLIスクリプト)を前提とします。RoadRunnerのワーカーやSwooleのコルーチン内など、それが成り立たないと判定できる場合は記録を開始しません。error_log()にその旨を出力し、ほかは何も変えずに記録オフのまま動作します(issue #179)。
開発時
既にQueryRepositoryModuleをインストール済みのアプリでは、override()で差し替えます。install()では既存のNullSemanticLoggerのbindingが残り、記録はオフのままです。
use BEAR\QueryRepository\DevQueryRepositoryLogModule;
$this->override(new DevQueryRepositoryLogModule($this->appMeta->logDir . '/query-repository'));
キャッシュプールがQueryRepositoryModule既定のNullAdapterのままだと、すべてのGETがmissになり、ログにhitは現れません。開発でもStorageRedisDsnModuleなどをインストールするか、ResourceObjectPoolとEtagPoolに永続するアダプター(FilesystemAdapterなど)を束縛します。
リクエストを1本実行すると、そのセッションがlatest.jsonに書かれます。ツリーで表示します:
vendor/bin/stree var/log/{context}/query-repository/latest.json
QueryRepositoryModuleをどこでもインストールしていないスタンドアロンのInjectorでは、コアのキャッシュ配線も一緒に渡します:
use BEAR\QueryRepository\QueryRepositoryModule;
$this->install(new DevQueryRepositoryLogModule(
$logDir,
module: new QueryRepositoryModule(),
));
本番時
ProdQueryRepositoryLogModuleは監視用ではなく、事後の原因調査用です。ヒット率やキャパシティはメトリクスの方が安価で正確に扱えます。古いコンテンツが見える、purgeが効かないといった問題で、メトリクスから原因が特定できないときに有効化します。既存アプリでは同じくoverride()を使います:
use BEAR\QueryRepository\ProdQueryRepositoryLogModule;
$this->override(new ProdQueryRepositoryLogModule(stream: 'php://stdout', sampleRate: 100));
mutation(書き込み)と、起きなかった効果(保存の拒否、無効化やpurgeの失敗)は常に保持されます。これらはほかに記録が残らず、確認できるのはこのログだけだからです。sampleRateは、問題が無かった健全セッションをN件に1件だけ残す割合です。0を指定するとサンプリングを無効化し、健全セッションは一切残しません。これと異なる保持ルールにしたい場合はRetentionPolicyInterfaceを自前で実装し、override()の後にbindしてください。writerはDI経由でこれを解決するため、アプリ側のbindingが勝ちます。
cache_hit、cache_miss、save_value、invalidate、cdn_headersなど、各エントリは自身のJSON Schemaへのリンクを持ちます。セッションはgrepではなく検証できます。
AIエージェント向け
上の設置と読解は、エージェントに任せられます。BEAR.EventSourcingに含まれるbear-observeスキルをプロジェクトに入れます:
composer require --dev bear/event-sourcing
mkdir -p .claude/skills && cp -r vendor/bear/event-sourcing/skills/bear-observe .claude/skills/
「キャッシュログを見て」と頼むと、エージェントが観測用のdev-文脈(DevModule、永続プール、bin/dev.php)をアプリに書き、配線を確かめ、リクエストを1本流してツリーを読みます。宣言した意図とログが食い違えば、アプリとライブラリのどちらの問題かを切り分けて報告します。
BEAR.Skillsもインストールできます:
/plugin marketplace add bearsunday/BEAR.Skills
/plugin install bear-skills
bear-cache-gate(キャッシュログを読み、フローごとに保存・hit・無効化が実際に起きたかを判定するゲートをvar/loop/に設置)が使えるようになります。ログの読解は上のbear-observeが引き継いでいます。
ログの全語彙と宣言された境界は、Why the QueryRepository Log Records Everything、Reading the Log、What the Cache Log Proves — and What It Does Notを参照してください。
結論
Webのコンテンツには情報(データ)型のものと計算(プロセス)型のものがあります。前者は本質的には静的ですが、コンテンツの変更や依存性の管理の問題で完全に静的コンテンツとして扱うのが難しく、コンテンツの変更が発生していないのにTTLによるキャッシュの無効化が行われていました。
BEAR.Sundayのキャッシングフレームワークは、情報型のコンテンツを可能な限り静的に扱い、キャッシュの力を最大化します。