Skip to content

Re-parse cached entries when the shape of what we cache changes (#320) - #322

Open
cboos wants to merge 2 commits into
mainfrom
dev/image-paste-ids-absent-320
Open

Re-parse cached entries when the shape of what we cache changes (#320)#322
cboos wants to merge 2 commits into
mainfrom
dev/image-paste-ids-absent-320

Conversation

@cboos

@cboos cboos commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes #320.

The bug

messages.content stores zlib(json(entry.model_dump())), but cache
invalidation observed only source_mtime, source_size and
subagents_fingerprint — every one of them a property of the file, none of
them a property of what we had written about it. Adding a field to a
transcript model therefore left every transcript unchanged since then serving
a blob without that field, for as long as the file stayed untouched.

imagePasteIds (#305/#306) is the first sighting. On a cache predating it the
field rehydrates as absent while the JSONL plainly carries it, so [Image #N]
placeholders that do have a recorded association are reported as having
none, rendered literally, and their images detached — the exact pre-#306
symptom, reinstated by the cache.

The warning in the issue is therefore accurate about what it was handed, and
wrong about the transcript.

Blast radius

Measured on one real archive, over messages that have placeholders, images and
recorded ids:

count
candidates 135
holding stale blobs 85
warned, refused to resolve 65
silently took the positional fallback 20
… of those, resolved correctly 20
resolved to a wrong image 0

The 20 are safe for the reason the fallback exists: on contiguous 1..k
numbering the recorded and positional readings coincide.

In a 4000-blob sample, imagePasteIds was the only missing declared field —
this is the hazard's first instance, not one of many.

The fix

cached_files gains a content_version column (migration 014) holding a
digest derived from the transcript models' own declared field names. A row
whose version differs is stale and re-parses once.

It is derived, not remembered: a future field moves the digest without
anyone bumping a constant. It keys on field names only, deliberately — so a
Pydantic or Python upgrade cannot mass-invalidate a multi-gigabyte cache. A
manual salt is available for the one case names cannot see: a field whose
meaning or serialized representation changes while keeping its name.

A NULL — the state migration 014 leaves on an existing cache — counts as
stale, unlike the NULL handling in migrations 007 and 011. Those columns
describe an input we might not have seen, so "no reason to think we missed
anything" is right. This one describes the completeness of our own output,
so unknown must mean incomplete. The one-time re-parse is the point of the
migration.

Verification

End-to-end, both arms on identical database state (blobs stripped of the
field, content_version NULL, HTML cache cleared, source file untouched):

before after
warnings 2 0
literal [Image #N] in the page 6 0
images rendered 4
blobs carrying the field 0 634

12 tests, three layers: the freshness rule, the digest's dependence on the
models (the pin — a new field must move it), and both freshness entry points
against the real schema. Each guard was neutralised in turn and reddens only
its own tests.

just ci green from a rebuilt venv.

Second commit: migrations claiming the same number

_schema_version keys on the migration number alone, so on a database
that already applied one of a colliding pair the other counts as applied and
its DDL never runs — silently, and only on the caches that upgraded through
the first one. The failure surfaces far downstream, as no such column.

This is why the migration here is 014: 013 is claimed by
perf/watch-tick-latency. On a database carrying that one, content_version
was never added while the runner reported the migration applied.

Neither branch can see the collision from its own tree — the pair exists in
one tree only once both merge, which is where the new check fires. A second
test guards the shipped directory.

Merge order: if perf/watch-tick-latency lands after this, its migration
number is the one that needs to move.

Summary by CodeRabbit

  • Bug Fixes

    • Updated cached transcript data when its content format changes, preventing outdated cache entries from being reused.
    • Existing cache entries are automatically reprocessed once when required.
    • Duplicate database migration numbers now produce a clear error instead of being silently accepted.
  • Reliability

    • Improved validation of cached data across individual and batch file checks.

`messages.content` stores zlib(json(entry.model_dump())), so a cached
blob is only as complete as the model that produced it. Freshness was
keyed on source mtime, size and the sidecar fingerprint -- every one of
them a property of the *file*, none of them a property of what we had
written about it. Adding a field to a transcript model therefore left
every transcript unchanged since serving a blob without that field, for
as long as the file stayed untouched.

Issue #320 is the first sighting. `imagePasteIds` shipped as a new field
on user entries; on a cache predating it the field rehydrates as absent
while the JSONL plainly carries it, so `[Image #N]` placeholders that do
have a recorded association are reported as having none, rendered
literally, and their images detached. Measured on one real archive: of
135 messages with placeholders, images and recorded ids, 85 held stale
blobs -- 65 warned and refused to resolve, and 20 took the positional
fallback (all 20 correct, since on contiguous numbering the recorded and
positional readings coincide). No message resolved to a wrong image.

`cached_files` gains a `content_version` column holding a digest derived
from the entry models' declared field names, so a future field moves it
without anyone having to remember to bump anything. A row whose version
differs -- or is NULL, the state migration 014 leaves behind -- is stale
and re-parses once.

That NULL handling differs deliberately from migrations 007 and 011,
which accept NULL as "no reason to think we missed anything". The column
they added describes an input we might not have seen; this one describes
the completeness of our own output, so unknown must mean incomplete.
`_schema_version` keys on the migration number alone, so on a database
that already applied one of a colliding pair the other counts as applied
and its DDL never runs -- silently, permanently, and only on the caches
that upgraded through the first one.

This was met rather than imagined. The migration added for #320 was
numbered 013, as was one on another branch in flight; on a database
carrying that one the `content_version` column was never added while the
runner cheerfully reported the migration applied, and the freshness
query then failed with "no such column". The #320 migration is 014 here
for that reason.

Neither branch can see the collision on its own -- the pair exists in
one tree only once both merge, which is where this check now fires. A
second test guards the shipped directory, so a future collision fails a
test run instead of reaching a user's cache.
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 7a92c57c-9fe6-407e-b238-5eac0787005d

📥 Commits

Reviewing files that changed from the base of the PR and between 5c574b8 and c60a7e2.

📒 Files selected for processing (4)
  • claude_code_log/cache.py
  • claude_code_log/migrations/014_cached_file_content_version.sql
  • claude_code_log/migrations/runner.py
  • test/test_cache_content_version.py

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The SQLite cache now stores a digest of transcript model fields. Freshness checks reject mismatched or legacy rows. Save and append operations write the current digest. Migration discovery now rejects duplicate migration numbers.

Changes

Cache content schema versioning

Layer / File(s) Summary
Schema version and migration validation
claude_code_log/cache.py, claude_code_log/migrations/014_cached_file_content_version.sql, claude_code_log/migrations/runner.py, test/test_cache_content_version.py
content_schema_version() hashes transcript model field names. Migration 014 adds the nullable content_version column. Duplicate migration numbers now raise ValueError. Tests cover digest stability, model-field changes, and migration validation.
Cache freshness and persistence
claude_code_log/cache.py, test/test_cache_content_version.py
Freshness checks reject mismatched or NULL content versions. Cache reads select the version, and save and append paths store the current version. Tests cover per-file and batched freshness checks and saved-row stamping.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to c60a7

The PR improves cache correctness by re-parsing entries when their stored shape is outdated. A database interruption during the schema migration could temporarily prevent the cache from starting until it is repaired or rebuilt, so the change is mergeable with explicit owner awareness of this recovery edge case.

Sequence Diagram(s)

sequenceDiagram
  participant CacheManager
  participant content_schema_version
  participant SQLite_cached_files
  participant _cache_row_is_fresh
  CacheManager->>content_schema_version: Compute transcript model digest
  content_schema_version-->>CacheManager: Return 12-character version
  CacheManager->>SQLite_cached_files: Save entries with content_version
  CacheManager->>SQLite_cached_files: Read cached row and content_version
  SQLite_cached_files-->>CacheManager: Return cached row
  CacheManager->>_cache_row_is_fresh: Compare stored and current versions
  _cache_row_is_fresh-->>CacheManager: Return fresh or stale
Loading

Suggested reviewers: daaain

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The cache versioning and migration 014 changes support issue #320. The duplicate migration-number validation in migrations/runner.py is a separate migration-safety enhancement that is not required b… Move the duplicate migration-number validation and its tests to a separate pull request, or document an explicit requirement that makes this migration-safety change part of issue #320.
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 24 functions across 3 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: cached entries are re-parsed when the cached model shape changes. It is concise and specific.
Linked Issues check ✅ Passed The PR satisfies issue #320 by versioning cached transcript content, marking mismatched or NULL versions as stale, and re-parsing affected entries. This restores support for model fields such as `imag…
Full details: Linked Issues check

Explanation

The PR satisfies issue #320 by versioning cached transcript content, marking mismatched or NULL versions as stale, and re-parsing affected entries. This restores support for model fields such as imagePasteIds in existing cache rows.

Full details: Out of Scope Changes check

Explanation

The cache versioning and migration 014 changes support issue #320. The duplicate migration-number validation in migrations/runner.py is a separate migration-safety enhancement that is not required by the linked issue.

Full details: Docstring Coverage

Explanation

Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 24 functions across 3 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev/image-paste-ids-absent-320

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Image references misattributed

1 participant