Skip to content

Last-Modifiedがキャッシュ(再)生成時刻を返すため、内容が不変でも検証子が変化する #181

Description

@suzumaze

概要

EtagSetterLast-Modifiedに呼び出し時点の現在時刻を設定するため、Last-Modifiedが「コンテンツの最終更新時刻」ではなく「キャッシュエントリの(再)生成時刻」を意味する状態になっています。特にドーナツキャッシュのリフレッシュ経路では、内容がバイト単位で同一でも、再合成のたびにLast-Modifiedが前進します。

RFC 9110 §8.8.2の定義では、Last-Modifiedは「オリジンサーバーが、選択された表現が最後に変更されたと考える時刻」です。本報告はこの定義との意味論的な食い違い(および再検証効率の低下)の指摘であり、RFC上の禁止事項への違反を主張するものではありません。

現状の実装

src/EtagSetter.php$timeが渡されなければ常に現在時刻を使います。

$time ??= time();
if ($ro->code !== 200) {
    return;
}

$etag = $this->getEtag($ro, $httpCache);
$ro->headers[Header::ETAG] = $etag;
$ro->headers[Header::LAST_MODIFIED] = gmdate(Header::RFC7231, $time);

src/HeaderSetter.phpEtagSetterを常に$time = nullで呼ぶため、HeaderSetter経由では時刻を注入する手段がそもそもありません。

($this->etagSetter)($ro, null, $httpCache);

src/DonutRepository.phprefreshDonut()はリフレッシュのたびにこのHeaderSetterを呼ぶため、Last-Modifiedは毎回「再合成した瞬間の現在時刻」になります。

($this->headerSetter)($ro, $donut->ttl, null);
$ro->headers[Header::ETAG] .= 'r'; // mark refreshed by resource static

なお通常の保存経路(QueryRepository::put())でも同様にLast-Modified=保存時刻ですが、putは実際の再生成時にのみ走るのに対し、リフレッシュ経路は「内容が変わらない再合成」でも走るため、症状が際立ちます。

問題点

  1. 内容が不変でも検証子が変化する
    サロゲートキーによるパージ後のリフレッシュで内容がバイト同一に再合成された場合でも、Last-Modifiedは毎回新しくなります。RFC 9110 §8.8.1が規範化しているのは「内容が変われば検証子も変わる」方向ですが、同節は(強い検証子の文脈ながら)「リモートキャッシュの無効化が必要なときにのみ値を変えることがオリジンサーバーの利益(best interests)である」とも述べており、本挙動はこの助言に反して不要な再検証・再取得を誘発します。

  2. 検証子ペアの矛盾
    リフレッシュ間の比較では、ETagのハッシュ部は同一のままLast-Modifiedだけ前進するため、「ETagは不変と言い、Last-Modifiedは更新されたと言う」組み合わせを返します。RFCは2つの検証子の相互整合を明示的には要求していませんが、意味論としては不整合です。

  3. Last-Modifiedを鮮度判定に使う下流キャッシュへの影響
    Last-Modifiedをヒューリスティック鮮度計算(RFC 9111 §4.2.2。経過時間の10%が典型と例示)に使うキャッシュでは、生成時刻由来の新しい日付が鮮度を実際より短く見せます。安全側ではあるものの、再検証が増える方向に働きます。

  4. BEAR自身はIf-Modified-Sinceを照合しない
    HttpCache::isNotModified()CliHttpCacheIf-None-Matchのみを参照しており、パッケージ全体にもIf-Modified-Sinceの照合箇所はありません。Last-Modifiedは「送出するが自分では一度も使わない」ヘッダになっています。日付のみで再検証するクライアントに内容不変でも常に200が返るのはこのためで、Last-Modifiedの前進とは独立の問題です。

実害が限定される理由(補足)

  • RFC 9110 §13.1.3により、If-None-Matchを含むリクエストではIf-Modified-Sinceは無視されます(MUSTレベル)。ETagを常に併送しているため、主要ブラウザ・CDN経由では通常マスクされます。
  • 顕在化するのは、If-Modified-Sinceのみで再検証する経路と、上記のヒューリスティック鮮度計算です。

検討の方向性

  • 案A: ドーナツキャッシュ(ビュー保存)モードではLast-Modifiedを送出しない
    ETagがあれば再検証は成立します。ただしQueryRepository::get()Age = time() - strtotime(Last-Modified)を算出しており、Last-ModifiedはAge計算の実働基準でもあるため、単純に削るとAgeが不正値になります。採る場合はAgeの基準を別フィールドへ移す変更が同時に必要です。

  • 案B: 内容が不変ならLast-Modifiedを維持する
    パージ後も生存するドーナツ雛形(ResourceDonut)は既にttl等を保持しているため、ここに生成時刻(と内容ハッシュ)を追加すれば、リフレッシュ時に内容不変を検出して従前のLast-Modifiedを持ち回せます。変更はResourceDonut周辺に閉じます。

  • 案C: 挙動は変えず、「Last-Modifiedは生成時刻」という現在の意味をドキュメントに明記する
    なおDevEtagSetterLast-Modifiedをepoch固定(0)にしており、「実時刻でないLast-Modified」の前例はコードベース内に既にあります。

意味論と影響範囲の両面から案Bが最有力ではないかと考えていますが、いかがでしょうか。

関連(別途報告予定)

  • ETagが引用符なしで送出される。RFC 9110 §8.8.3のABNF

    entity-tag = [ weak ] opaque-tag
    opaque-tag = DQUOTE *etagc DQUOTE
    

    はDQUOTE(二重引用符)を必須とするため、構文不適合です。

  • リフレッシュ経路でETag末尾に'r'が後置され、同一内容でも生成経路によってETagが変わる

これらは本issueとは独立にPRを予定しています。

環境

  • bear/query-repository 1.16.0(デフォルト束縛のEtagSetter
  • upstreamデフォルトブランチ1.xのHEADでも該当ファイルが同一実装であることを確認済み(2026-07-27時点)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions