Skip to content

CHA-545 | key the cold segment cache by content hash, not row uuid - #39

Merged
nhobin219 merged 32 commits into
mainfrom
nhobin219/cha-545-segment-cache-content-hash
Jul 31, 2026
Merged

CHA-545 | key the cold segment cache by content hash, not row uuid#39
nhobin219 merged 32 commits into
mainfrom
nhobin219/cha-545-segment-cache-content-hash

Conversation

@nhobin219

@nhobin219 nhobin219 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Closes CHA-545.

The problem

SegmentCache was keyed by the cold-artifact row's uuid. Reference copies mint
a new row uuid over an unchanged (object_uri, offset, length):

  • insert_carried_snapshot_segments / insert_carried_segment_indexes carry a
    snapshot forward (CHA-531), and
  • fork_copy materializes a fork's cold references at fork time (CHA-539).

Nobody rewrites the bytes. A uuid-keyed cache therefore stored N identical
decodes of one byte range — N× the memory under a fixed byte budget, and N cold
reads to fill it. A fork's cold footprint is mostly its parent's files, so N
grows with the branch count, which is the thing Penca expects to be cheap.

The ticket asked whether this is worth solving. It is: the duplication scales
with exactly the operation the product markets as free.

The change

Every cold-artifact metadata row — persist segments, snapshot segments, and
segment index sidecars — now carries a content_hash: an xxh3_128 digest of
the typed in-memory Arrow batch, computed once at write time
(penca_core::digest::segment_content_hash). SegmentCache is keyed by that
hash, and every reference copy inherits the source row's hash verbatim — that
inheritance is the dedup.

Hashing the decoded batch rather than the encoded file bytes is deliberate: the
key has to name one decoded batch (equal hash must mean equal decode), and the
digest is available where it is taken — at write time, before the format writer
encodes. It also makes dedup insensitive to encoding choices, so two
independently written segments holding the same rows share an entry even if the
writer picked different row-group boundaries or dictionary encodings.

The part that is not just a key swap

A hash-keyed entry is shared across callers whose schemas can differ. Carry-
forward and fork copy select old.content_hash verbatim, so when a fork ALTERs
a column its rows still read the parent's bytes under the parent's hash — parent
and fork share one cache entry while disagreeing about that column's type. No
hashing scheme can separate them, because neither row's digest was ever taken
under its own read schema.

So the cached value had to become the file-native decode, with caller
shaping (projection + null-fill of columns added by a later
ALTER TABLE ADD COLUMN) moved after the lookup:
FormatReader::read_segment_native + reader::shape_to_schema. A caller-shaped
entry would carry the schema of whichever branch decoded first, and the second
branch's read would fail on a type mismatch its own metadata never justified.
test_fork_and_parent_diverge_a_columns_type_over_one_shared_slice is the
regression guard.

Why the storage format is deliberately not in the key

Digesting the batch pre-encode is exactly what lets one hash name files in two
formats, once OBJECT_STORAGE_FORMAT has been flipped between two writes of the
same content. Those two files decode to the same Arrow batch under Penca's
storage contract — a write followed by a read returns the batch that was written
— so the hash alone names one decode and the two files share one entry.

A review pass folded the format into the key on the argument that the cached
file-native decode is a per-format artifact (6d755e2); 2a6cd96 reverses it.
That inverts the contract: a Parquet/Lance divergence would mean one format's
round trip is wrong, fixed in that format or by dropping the type from
CanonicalType — not a fact for a cache key to absorb. Nothing asserts the
contract today, but keying around an unverified defect gives up a real
cross-format share permanently and would mask the defect if it existed, turning
the loud cross-format type mismatch into silence. CHA-548 adds round-trip
identity coverage driven from CanonicalType.

The alternative that was rejected

The symmetric design fingerprints the read-time schema into the key and caches a
value already adapted to it — in its strongest form, the type-cast table, with
projection and null-fill still applied per caller. It is implementable and
correct; the fingerprint separates the two branches above. It loses because the
key then moves whenever the schema moves while the data does not: one
ADD COLUMN re-fingerprints a footprint nobody rewrote, and a fork stops sharing
with its parent at their first divergent ALTER — which is the dedup this PR
exists to create. Its one genuine advantage, serving a read whose declared type
differs from the file's, has nothing to bite on while schema evolution is
ADD COLUMN-only; and if type evolution lands, casting belongs in the shaping
tail where it is per-caller and visible, not hidden behind a cache key.
docs/design-decisions.md carries the full comparison and a worked example,
since none of this is self-evident from the code.

Index sidecars are in scope for the same reason base segments are: they come out
of object storage through the same SegmentCache and are reference-copied by the
same two mechanisms.

content_hash is UUID NOT NULL with no default on all three tables. Every
writer computes it and a catalog predating the column is recreated rather than
migrated, so there is no legacy row to default — the uuid fallback key space is
deleted, not kept alive. tx_log_persist_segment_metadata deliberately has no
content_hash: it is never cache-read and never reference-copied.

Commits

Red tests

  • 0d0a9fe test(cold) — failing cross-branch segment cache dedup tests
  • 1343c25 test(cold) — cross-branch schema-divergence regression guard

Digest + schema

  • 401f38e feat(cold)segment_content_hash over typed Arrow batches
  • 0bd3200 fix(cold) — drop the no-op normalization, pin dictionary handling
  • 50f177d feat(schema)content_hash column on segment + sidecar metadata

Write + copy paths

  • 8b1482c feat(cold) — compute segment_content_hash at every fresh cold write
  • 3dddb34 feat(cold) — inherit content_hash on every reference copy

Read path

  • f244986 refactor(query) — cache the file-native decode, shape after the lookup
  • 75cb567 feat(query) — key SegmentCache by content_hash
  • 6435833 perf(query) — take matched rows before shaping on the index-seek path

Cleanup pass (orch:run-cleanup)

  • f401463 refactor(cold) — key SegmentCache by Uuid, not a stringified hash
  • 7043699 refactor(cold) — extract requested_columns from the format readers
  • e1fe2e5 refactor(cold) — name the projection-less shaping call in the driver
  • 6687637 refactor(cold) — collapse three decode-and-cache copies into one helper
  • ec961b6 feat(cold) — report the sidecar cache miss as cached vs uncached

Plus test-fixture and docs commits (bb60e6e, 55102b6, 48e6eaa, b4b1539,
b8310a2, 1a16aab, 3fd2cef, b1c4ac8, 1b7736f).

Review follow-ups (/review-pr, folded in rather than deferred)

  • 6d755e2 fix(cold) — fold the storage format into the segment cache key
  • 97ad00a chore — cargo fmt the segment cache key call site
  • b2100a4 docs(cold) — reconcile the sidecar collision note with the key space
  • 7308732 refactor(cold) — name the segment cache key as a type
  • a3ae1bd docs(cold) — record why the cache holds the native decode
  • 2a6cd96 refactor(cold) — drop the format from the segment cache key
    (reverses 6d755e2; see the section above)
  • 2b57ac7 perf(cold) — compact byte-view buffers before hashing a segment
  • a78f281 fix(cold) — keep the row count when compacting a zero-size fixed list

Those last two are worth calling out. arrow-ipc emits a view array's variadic data
buffers whole rather than prove no surviving view points into a pruned one,
so a digest of a slice encoded the whole parent's string payload — and compact
and the snapshot packer both hash once per slice of one concat_batches result,
so a wave of N inputs re-serialized every input's bytes N times, on exactly
the many-small-segments workload compaction exists for. gc-ing the byte-view
columns first (descending one level into a list child, which is as deep as
CanonicalType allows a view to sit) bounds each digest to its own rows and
makes it canonical for those types: two segments holding the same values now
agree even when their parents laid the bytes out differently. The digest's
contract loses its slice-invariance exception rather than documenting one.

a78f281 fixes a bug that compaction introduced: FixedSizeListArray::try_new
does not preserve its input's length — at size == 0 it re-derives len off the
null buffer, so a null-free FixedSizeList(Utf8View, 0) rebuilt as zero rows
and two such columns digested identically regardless of row count. CanonicalType
accepts any i32 size including 0, so nothing upstream rejects it.
try_new_with_length carries the length through. The same commit covers the
BinaryView, LargeList<view> and FixedSizeList<view> compaction branches,
which the first pass left untested — including a guard pinning the arrow behavior
the FixedSizeList arm rests on (slice narrows its child, unlike the offset
flavours), so a future arrow release that changed it would fail loudly instead of
silently re-absorbing neighbouring rows' bytes.

Rebases onto merged work

#23 (CHA-539). Merged first; the rebase landed the fork_copy.rs half
intact: all three copy statements (table_persist_segment_metadata,
segment_index_metadata, snapshot_segment_metadata) select old.content_hash
verbatim. No TODO(CHA-545) is left anywhere in the tree.

#37 (CHA-546). Merged during review; rebased onto it, conflicting in
fork_copy.rs and persist.rs where both changes edit the same SQL. #37 made
the copy statements name the parent and child partitions separately
(FROM {seg_old} old) and bind the already-parsed branch uuid; this PR adds
old.content_hash to the same SELECT lists and a content_hash bind to
repoint_table_persist_segment. Both halves are kept in each of the four
resolutions.

Out of scope

Round-trip identity coverage. CHA-548 — write a fixture batch per
CanonicalType variant through each FormatWriter, read it back via
read_segment_native, assert schema and data equality, and assert the two
formats agree. That is the invariant this PR's key assumes; penca-format is the
one consumer that never matches on CanonicalType, so CHA-386's cross-crate
exhaustiveness guarantee does not currently reach it.

Checksum reuse. CHA-545 names it as a possible second use of this digest. It
is not available yet: the digest is taken on the in-memory batch before the
format writer encodes, so verifying a stored file against it means re-deriving it
from a decode — sound only once round-trip identity is established. CHA-548 is
that gate too.

Schema-directed decode. Neither reader consults the table's stored
point-in-time arrow_schema; Parquet decodes under builder.schema() and Lance
under reader.metadata().file_schema, and nothing in penca-format casts. That
predates this PR — old read_segment and new read_segment_native share the
same read_in_file_schema. Directing the decode from the stored schema would
make the decoded types a function of metadata rather than of the encoder, which
would turn the cross-format assumption above into a guarantee rather than
something CHA-548 has to keep testing. Recorded as an open question rather than
built here.

Verification

Red-verified. The two red-test tasks were written first and observed failing
before any implementation: the dedup tests (0d0a9fe) and the cross-branch
schema-divergence guard (1343c25). The latter is the one that forced the
native-decode split — with a caller-shaped cache value it fails on a type
mismatch the fork's own metadata never justified.

Green.

  • just check — full workspace gate, green on the final tree
  • just cargo-clippy — clean on the final tree
  • Full integration suite on a freshly-wiped --profile=test stack, against the
    final tree: 729 passed, 16 skipped, 0 failed (serial phase 66, parallel
    phase 663)

Branch CI skips integration entirely — it runs on merge_group, so the queue is
the authoritative gate — which is why the suite was run by hand from scratch
after each round of review follow-ups, after the rebase onto f557d1c, and once
more on the final tree. Counts moved only by what main added (721 → 729 as #37
landed); zero failures throughout.

That full-suite run is what caught 1b7736f: a white-box test hand-INSERTs a
fake segment row from an explicit column list that predated content_hash, and
the new NOT NULL broke it. No other white-box insert touches an affected table
— the purge_tx_log ones write tx_log_persist_segment_metadata, which
deliberately has no content_hash.

🤖 Generated with Claude Code

@nhobin219 nhobin219 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Penca PR Review — CHA-545 content-hash-keyed segment cache

Verdict: comment-only (own PR — event=COMMENT). No Critical findings. The load-bearing correctness split — cache the file-native decode, shape per caller after the lookup — is implemented correctly and completely. One Important finding on the key space, one Suggestion on contradictory docstrings.

CI is fully green (Rust clippy + fmt + test, Python unit tests, CodeQL, all Analyze jobs).

What I verified (rather than took on trust)

  • Exactly one cache.insert in the whole workspace (crates/penca-dl/src/driver.rs:236, inside decode_and_cache_native), and it always stores reader.read_segment_native(...). No path caches a caller-shaped batch; no path shapes before caching.
  • All three read entry points shape after the lookup, including on the hit branch: read_cached_snapshot_segment (via _unshaped + CachedSegment), read_cached_persist_segment (return shape_native(&full, full_schema) on hit), read_cached_index_sidecar (segment_index_schema(key_types) computed before the lookup, applied after). non_nullable_missing_column_errors_on_a_cache_hit_too pins exactly the branch a regression would hide in.
  • take_matched_rows takes matched rows before shaping, keeping the seek path O(matches) rather than O(segment); guarded by seek_snapshot_point_null_fills_a_column_absent_from_the_file.
  • Every fresh cold write hashes the exact typed batch it hands the writer: persist and snapshot step.batch (durable_writer.rs), per-partition slices in packer.rs::flush / single_partition_file, the empty-merge placeholder, and the sidecar batch that write_segment_index actually writes.
  • compact.rs correctly recomputes per repointed slice instead of carrying the input row's hash, and the (cumulative, meta.row_count) slice offsets match what the repoint writes. The rationale comment (concat_batches normalizes to one segment_schema, so a slice can decode wider than its input file) is the right reason.
  • Every reference copy inherits old.content_hash verbatim: fork_copy.rs ×3 (snapshot segments, sidecars, persist segments), plus both CHA-531 carry-forwards in snapshot.rs and segment_index.rs. No path mints a fresh hash on a copy.
  • No cold write path into the three tables omits content_hash. The remaining penca-storage-meta INSERTs target parent/header tables that legitimately have none, and there is no Python writer for these tables.
  • The "recreate, don't migrate" claim checks out against the pre-existing comment in crates/penca-db/src/dialect/pg.rs:586 ("This DDL only runs at CreateCatalog; pre-release there is no in-place migration path — recreate catalogs that predate a schema change"). No legacy rows, so no uuid fallback key space is needed.
  • The nil-hash escape is genuinely unreachable. The two production ..PersistSegment::default() sites (query/mod.rs:2059, :2541) both feed ColdStorageClient::read_persist_segments, which goes through read_one_persist_segmentnot read_cached_persist_segment. provider.rs:233 is the only production cache-read construction and its segments come from meta_plan.rs, built field-by-field off a NOT NULL column. The plan.rs comment defending the Default is accurate.
  • Weight accounting did not regress. admits() and the weigher are unchanged and still charge size_bytes (the in-memory Arrow footprint, same unit as the budget). The native decode is if anything smaller than the old caller-shaped value.
  • Skill invariants clean: no bare SessionContext::new() on any cold path (all hits are tests or comments); penca-api has no datafusion dependency and no use datafusion. All 24 commit headlines are conventional with in-repo scopes (cold, schema, query).

Strengths

  • The read_segment_native / shape_to_schema split is the right factoring, not a workaround: read_in_file_schema is shared by both arms in parquet.rs and lance.rs, and the concat_batches anchor correctly moved to batches[0].schema() (file types) rather than the caller's.
  • crates/penca-core/src/digest.rs documents the slice-invariance contract by Arrow type class and pins the one case where it fails (Utf8View/BinaryView, because the writer emits variadic data buffers whole) with a test named for it — sliced_view_column_is_the_documented_non_invariant_case. That is exactly the kind of boundary that would otherwise be discovered in production.
  • meta_plan.rs::decode_child_sidecar deliberately uses a non-Option row.get::<Uuid, _>("sidecar_content_hash") with a comment saying why unwrap_or(Uuid::nil()) would alias every sidecar onto one entry. Correct instinct, correctly documented.
  • The integration guard doesn't just assert the divergent read — it joins on content_hash across both the persist and snapshot tiers and raises if shared == 0, so the test can't silently degrade into a tautology when the fork stops sharing slices.
  • IndexSidecar deliberately having no Default (unlike the two segment types) is a well-reasoned asymmetry, not an oversight.

Comment thread crates/penca-dl/src/cache.rs Outdated
Comment on lines +55 to +57
/// to the same batch may safely share. `format` is likewise absent from the
/// key: every value is a format-agnostic decoded [`RecordBatch`], and `format`
/// is consulted only on the miss path (by the caller) to pick the reader.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: format lost its functional dependency on the key, but stayed out of the key.

The docstring this line replaces justified the omission with a premise the PR removes:

format is intentionally absent from the key — it is functionally determined by the uuid, and every value is a format-agnostic decoded RecordBatch

Under uuid keying that held: one segment_uuid → one metadata row → one format. Under content_hash keying it does not. content_hash is a digest of one pre-encode typed batch, so two metadata rows carrying the same hash may name files written by different FormatWriters. The replacement text keeps the conclusion but substitutes a premise that this PR makes false for the value: after the native/shape split the cached value is deliberately the file-native decode (read_segment_native) — precisely the format-dependent artifact, not a format-agnostic one.

Mechanically, the lookup happens before the reader is chosen. In all three call sites (driver.rs:314, :452, :515) cache.get(&segment.content_hash) runs above segment.format.as_wire_code(), so a hit never consults the row's format at all.

Reachability: ObjectStorageConfig::build_readers() populates a HashMap<i32, R> of every configured format and dispatch is per metadata row, so one process can hold parquet- and lance-decoded values in the same cache. OBJECT_STORAGE_FORMAT is deployment-wide at write time, so a cross-format duplicate needs the same typed batch written once under each format — an OBJECT_STORAGE_FORMAT flip is the reachable path, and this PR itself names the content most likely to recur across one: snapshot_op.rs::empty_merge_placeholder_step ("Every empty placeholder over one schema is the same zero-row content, so they legitimately collapse onto one cache entry").

Blast radius is bounded — the shaping tail (shape_to_schemaRecordBatch::try_new) hard-errors on a type mismatch rather than silently returning wrong types — but "which reader decoded this" is no longer a property the key preserves, and where the two natives happen to agree structurally the wrong-format value is served without complaint.

Cheap fix, either shape:

  • key on (Uuid, i32) with format.as_wire_code() as the second component, or
  • fold the wire code into segment_content_hash so the key stays a plain Uuid.

Either restores the functional dependency the pre-PR comment was resting on. At minimum the sentence should stop asserting the value is format-agnostic, since the paragraph three lines above it says the opposite.

Comment thread crates/penca-dl/src/driver.rs Outdated
Comment on lines +487 to +489
/// cache, keyed by the sidecar's own `content_hash` — a digest of the sidecar
/// batch, which differs from any base segment's, so the two never collide in one
/// cache.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: this sentence and the SegmentCache docstring take opposite positions on cross-artifact-class collisions.

Here: the sidecar's hash "differs from any base segment's, so the two never collide in one cache."

crates/penca-dl/src/cache.rs:51-55: "two entries collide only when their decoded batches are identical, in which case sharing one entry is the correct answer rather than a bug — a base segment and a sidecar that decode to the same batch may safely share."

The cache.rs framing is the sound one, and it's the one that makes the flat key space defensible: a same-content collision is by construction safe, so no per-class prefix is needed. The "never collide" claim here is both stronger than needed and not enforced anywhere — it happens to hold because the sidecar schema is (key_0..key_{n-1}, row_offset) and a base segment's never is, but nothing checks that, and if it ever stopped holding the cache.rs argument says it still wouldn't be a bug.

Suggest dropping the "so the two never collide in one cache" clause and pointing at SegmentCache's key-space paragraph instead, so there's one statement of the invariant rather than two that disagree.

Nico Bautista Hobin and others added 29 commits July 31, 2026 19:26
Reference copies (snapshot carry-forward CHA-531, fork copy CHA-539) mint a
new row uuid over an unchanged (uri, offset, length), so the uuid-keyed
SegmentCache stores N identical decodes of one byte range.

Add content_hash: Uuid to PersistSegment, SnapshotSegment, and IndexSidecar
plus the two Default impls, and three driver tests asserting that two rows
sharing a content_hash decode once. All three fail today (counter 2, want 1)
— one per artifact class, because the scope rule is that segments and index
sidecars dedup identically.

CHA-545

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
xxh3_128 of the decoded in-memory batch, canonically encoded via Arrow IPC
with explicitly pinned options (alignment, no compression, MetadataVersion
V5) so the digest cannot shift under an arrow-rs default change.

Hashing the typed batch rather than the encoded file bytes is what makes it
schema-sensitive: two segments over the same (uri, offset, length) slice
under different schemas are different decoded objects and must not collide.

Doc comment records that this is write-time only — a pre-write digest is not
known to survive a format round-trip, so the checksum reuse CHA-545 mentions
needs round-trip stability established first.

CHA-545

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test doc claimed the normalization was what kept a sliced batch from
encoding its parent's out-of-range bytes. Measured: removing concat_batches
leaves the test green, because arrow-rs's IPC writer already encodes only
the logical range. Say what was observed rather than what was assumed.

CHA-545

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add `content_hash UUID NOT NULL` to the three cold-artifact metadata
tables (persist segment, snapshot segment, snapshot segment index) and
carry it from row to plan struct on every read path.

NOT NULL with no default: catalog metadata has no in-place migration
path, so a catalog predating this change lacks the column, not the
value — it is recreated. There is no nullable arm and no fallback key
space.

`decode_child_sidecar` reads it non-Option, following
`segment_index_uuid` rather than the `size_bytes` `unwrap_or(0)` beside
it: a nil default there would be one shared cache key for every
sidecar, serving segment X's index for segment Y.

Test fixtures derive a per-name hash via `deterministic_uuid_from`
rather than defaulting to nil, so distinct fixtures stay distinct once
the cache keys on this column.

Restores workspace compilation after the field additions in the red
commit. Verified with `cargo check --workspace --all-targets` and
`cargo test -p penca-core`.

CHA-545
Every pre-existing penca-dl fixture was seeded `Uuid::nil()`, so all
distinct fixtures in a test shared one future cache key. Once
SegmentCache keys on content_hash that turns
`evicted_segment_re_reads_from_storage` into a hit on the wrong entry,
and makes the provider's identity-vs-user sidecar pair silently serve
one sidecar's batch for the other — a wrong-result path a passing test
would mask.

Fixture helpers and every explicitly-seeded literal now derive the
hash from their own identity field via
`naming::deterministic_uuid_from`, so distinct fixtures stay distinct;
only the tests deliberately asserting dedup share a value.

Left as-is: five single-segment persist fixtures whose test builds its
own SegmentCache, where there is no second entry to alias with.

The three CHA-545 dedup tests remain red for their own assertion
(2 reads != 1); the other 82 penca-dl tests pass.

CHA-545
`concat_batches` was credited with making the digest
representation-independent for nested columns. It cannot: `concat`
short-circuits single-array input to `slice(0, len)`, rebuilding
nothing.

Measured on arrow-rs 57.3 across Int32/Int64/Utf8/List — with and
without the call — slice-invariance holds either way and rests solely
on the IPC writer encoding the logical range. Dictionary columns are
the one measured exception, and `concat_batches` does not fix them
either; Penca rejects `DataType::Dictionary` at the type boundary, so
none reaches this function.

Drops the call, adds a List slice test (the nested case the old
comment gestured at but never covered) guarded on the child array
staying whole, and pins `DictionaryHandling::Resend` so the claim that
nothing here relies on a library default is actually true.

CHA-545
`indexed_segment` and `composite_indexed_segment` set an explicit
`table_snapshot_segment_uuid` but left `content_hash` at the nil
Default. Two tests feed two of those base segments to one shared
`SegmentCache`, so once the cache keys on the hash both would collide
onto one entry and serve segment A's batch for segment B.

CHA-545
The slice-invariance note enumerated the supported set as
Int32/Int64/Utf8 plus the three list types, and named dictionaries the
only exception. Both were wrong: `CanonicalType::from_arrow` accepts
every scalar, decimal, and temporal type plus `Utf8View`/`BinaryView`,
and it is the *view* encodings — not dictionaries, which are rejected at
the type boundary — that are the reachable exception.

Measured across the supported set on arrow-rs 57.3, including `Boolean`
at a non-byte-aligned offset. The writer truncates a view array's views
buffer but emits each variadic data buffer whole, so a sliced view column
above the 12-byte inline threshold carries bytes for rows it does not
cover, top-level or as a list child. Pinned by a canary test so the
contract cannot drift again.

Cost is a missed dedup between independently written segments, never a
wrong result and never lost fork sharing — a reference copy inherits the
stored hash rather than recomputing it.

CHA-545
Removed in 86dee41 along with the slice-keying design it was written
against. The scenario it pins is independent of how the cache is keyed:
any keying that lets a fork and its parent share one entry can hand a
caller-shaped decode to a branch expecting a different type for the
column it ALTERed independently. Content-hash keying reaches it the same
way slice keying did, so the guard comes back with the docstring
re-grounded on CHA-545's mechanism.

Both load-bearing properties preserved: the child-then-parent read order
(the child's decode is the miss that populates the cache; only the
parent's assertion is the guard) and the precondition that RAISES across
both cold tiers rather than asserting, so a fixture that stopped
producing a shared slice fails loudly instead of silently turning the
guard into a no-op.

CHA-545
`content_hash` was added as NOT NULL with no writer, so every cold write
failed on the not-null constraint. Populates it at the four live insert
sites plus the currently-callerless snapshot `write_segment` arm, each
hashing the same in-memory batch the sibling `compute_segment_statistics`
call already consumes:

- persist segments, in `PersistSegmentScope::insert_segment`
- packed snapshot segments, per partition slice in the packer, so the
  hash is partition-tight like `size_bytes`/`statistics` rather than
  covering the whole packed file
- index sidecars, over the sidecar batch rather than its base segment:
  two segments with different rows can build identical indexes, and it
  is the index batch the cache entry holds
- the empty-merge placeholder, where every zero-row batch over one
  schema legitimately collapses onto a single entry

Compact re-points recompute per slice instead of carrying the input
row's hash. Rows survive a merge 1:1, but `concat_batches` normalizes
every input to one `segment_schema`, so a slice of the merged batch can
decode to a wider type than its input file did; carrying the old value
would leave two different decodes sharing one cache key.

CHA-545
Both reference-copy families mint a new row uuid over an object nobody
rewrote, which is exactly the duplication CHA-545 is about and the only
place cross-branch dedup is established. Each copy now carries the source
row's `content_hash` verbatim — no recompute, no file read — by adding
one column to the existing explicit `INSERT … SELECT old.<cols>`
projections:

- carry-forward: `insert_carried_snapshot_segments` and its sidecar twin
  `insert_carried_segment_indexes`, both also extending `DO UPDATE SET`
  so a crash-retry refreshes the column like its neighbours
- fork copy: the snapshot-segment, index-sidecar and persist-segment arms
  in `fork_copy.rs`, which are `DO NOTHING` and gain no update clause

The parent-header arms (`table_snapshot_metadata`, its index parent, and
`table_persist_metadata`) are deliberately untouched: they name no
object-storage artifact, so a `content_hash` there would be a column with
no writer and no reader.

Asserted on the steady-state carry test, which already proves carry-by-
reference via a shared `object_uri` — base segment and sidecar both.

CHA-545
The narrowing clause said view columns stay invariant below the 12-byte
inline threshold. Measured false for a mixed-length parent: the IPC
writer emits each variadic data buffer whole, and one long value
anywhere in the parent gives the array those buffers, so a slice of only
short rows still hashes differently from a standalone batch of the same
rows. A reader whose view columns are short would have concluded
invariance from the old wording.

Pins the mixed case in the canary test alongside the all-inline one, so
the assertion messages no longer state a rule broader than the fixture.

CHA-545
The precondition probed the old slice-keying condition — matching
object_uri and offset. Under content-hash keying that predicate no
longer names what makes the fork and the parent share a cache entry, so
a copy that minted a fresh hash instead of inheriting one would leave
the addressing identical, pass the precondition, and turn the guard into
the silent no-op its raise exists to prevent.

Joins on content_hash first, keeping the addressing columns (now
including length) to pin that the collision comes from a reference copy
rather than two independent writes landing on the same bytes.

CHA-545
Every cache-populating read decoded to the caller's schema and cached
that. Once two callers with different schemas share one entry — which is
what content-hash keying is about to make routine — the second gets the
first's types.

Adds FormatReader::read_segment_native, which returns the file's own
columns in the file's own types with only the (offset, length) slice
applied, so row count and row order still line up with the sidecar
offsets seek_row_offsets and take_matched_rows index into. Both real
readers now route their projected and native reads through one
read_in_file_schema, so the native path is not a second read
implementation and the projected path keeps its column pushdown.

The shaping tail — project_schema then null_fill_to_schema — is lifted
verbatim into shape_to_schema and re-run in penca-dl after the cache
lookup, on the hit and miss paths alike, for base segments, persist
segments and index sidecars. Sidecars are the same bug, not an
exemption: the file carries the writing branch's key column types while
key_types comes from the reading branch's schema.

Pins that NonNullableMissingColumn still fires on a hit, red-verified by
returning the cached batch unshaped and watching it pass instead.

Cache keying is untouched here — still per-uuid.

CHA-545
The cache stored one decoded entry per metadata row uuid, so a fork and
its parent held two copies of byte-identical cold data: snapshot
carry-forward (CHA-531) and a fork's cold materialization (CHA-539) both
mint a new row uuid over bytes nobody rewrote. Keying by the segment's
`content_hash` — the digest of the typed in-memory batch, inherited
verbatim by every reference copy — stores one entry per distinct content
instead, so the second branch's read is a hit.

All three cached artifact classes move together: snapshot segments,
persist data segments and index sidecars. The uuids stay in the
`#[tracing::instrument]` span fields, which is what correlates a log line
to a metadata row; `content_hash` joins them rather than replacing them.

CHA-545
Shaping moved behind the cache lookup so each caller gets its own types,
but `take_matched_rows` then shaped the whole cached segment to
`full_schema` before narrowing it to the matched offsets. For a segment
written before an `ALTER TABLE ADD COLUMN`, that allocates a
segment-length null array on every probe — O(rows) work reintroduced on
the path whose whole point is O(matches).

Split the cache-aware read into an unshaped `CachedSegment` so the seek
path can `take` first and null-fill only the taken rows. The scan path is
unchanged: it needs every row shaped anyway.

CHA-545
CHA-545's acceptance criterion is a decision, not a behavior: a content
hash on every cold-artifact metadata row is non-obvious complexity, so
record why it exists where a future reader finds it — including the two
points most likely to be re-litigated, why the digest is over the typed
batch rather than the encoded file bytes, and why index sidecars are in
scope alongside base segments.

CHA-545
content_hash is a Uuid everywhere it is defined (plan.rs) and stored
(UUID NOT NULL in all three cold-artifact tables). SegmentCache was the
one boundary that downgraded it to a String, so every crossing rendered
a 16-byte value as a 36-char hex string — cache.get allocated a String
per segment on every cold read, hit or miss, purely to index a hash map.

Key the moka cache by Uuid directly. The borrowed-get / owned-insert
split is unchanged (moka owns the key on insert; &Uuid on lookup matches
the workspace convention for Uuid parameters), so the only call-site
change is dropping .to_string().

CHA-545
Both FormatReader::read_segment impls opened with a byte-identical
four-line match resolving 'projection when given, otherwise every column
in schema' — a rule the trait documents once (reader/mod.rs) but that
was implemented twice, with nothing forcing the two to stay in lockstep.

Lift it into reader/mod.rs beside present_columns, which is the same
shape: a pub(crate) reader helper with the same two call sites.

CHA-545
The rationale claimed hashing the typed batch is what keeps a fork's
rows from colliding with the parent's over a shared byte slice. It is
not: every reference-copy path selects old.content_hash verbatim
(fork_copy.rs, snapshot.rs, segment_index.rs) and it is never
recomputed, so parent and fork carry the SAME hash under any scheme —
that inheritance is the dedup. No digest can separate them because
neither row's was taken under its own read schema.

That contradicted the very next paragraph, and a reader who believed it
could conclude the native-decode split is redundant and reintroduce the
cross-branch type mismatch it exists to prevent. State the property the
digest actually buys (equal hash means equal decode, plus dedup that
survives re-encoding) and name the split as the schema-divergence
answer. Same correction in segment_content_hash's own doc.

CHA-545
Six sites repeated shape_to_schema(.., None).map_err(ColdStorageError::from)?,
restating an invariant none of them asserted: after the decode/shape split
every cache-path shaping call passes the caller's FULL schema with no
projection, because pruning is DataFusion's job (ADR 0023). A reader had
to notice the None at all six sites to learn that.

CHA-545
The snapshot, persist and index-sidecar cacheable-miss paths each had their
own read_segment_native → Arc::new → cache.insert → debug! sequence, two as
named helpers and one open-coded. They differed only in which fields of which
segment type supplied the uri, slice bounds, content hash and weight — nothing
about the decode or the insert. decode_and_cache_native takes those four as
plain arguments, mirroring FormatReader::read_segment_native, so no artifact
type reaches it.

The sidecar path gains the `rows` debug line the other two already had, and
the three per-path log texts collapse to one "segment decoded and cached";
the span already carries content_hash and the artifact's own uuid, so the
message never carried the distinction.

CHA-545
CHA-545 unified the three cold-artifact classes into one SegmentCache under one
content_hash key space, but the sidecar span reported a two-valued `cache`
field where the two base-segment paths report "hit" / "miss-cached" /
"miss-uncached", so no single query spanned the cache without special-casing
one of the three classes just unified.

It also left the oversize case invisible: read_cached_index_sidecar relies on
insert's self-gate rather than calling admits, so a sidecar larger than the
budget is re-decoded from S3 on every read forever and logged as a plain
"miss", indistinguishable from a first touch. The admits call added here is
telemetry-only — a sidecar has no narrower read to fall back to, so it is still
decoded whole.

CHA-545
The white-box INSERT builds a fake table_persist_segment row from an
explicit column list, which predates content_hash. That column is now
UUID NOT NULL with no default, so the raw insert fails before the test
can assert what it is about — that a segment with NULL commit_micros
stays out of the read plan.

CHA-545

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keying on content_hash alone assumed the cached value was format-agnostic.
It is not: this PR deliberately caches the *file-native* decode, which is a
per-format artifact — a round-trip may widen a type or re-dictionary-encode.
And content_hash digests the typed batch *before* a FormatWriter encodes it,
so one hash can name a Parquet file and a Lance file once OBJECT_STORAGE_FORMAT
has been flipped between two writes of the same content. All three lookups ran
cache.get above format.as_wire_code(), so a hit never consulted the row's
format at all, and one format's decode could be served for the other's file.

The key becomes (content_hash, format wire code). Folding the format into the
key rather than into the digest keeps content_hash a pure function of the
batch, which is what lets a reference copy inherit it verbatim.

Red-verified: neutering the format half of the key fails
same_hash_under_two_formats_does_not_share_an_entry on the Lance lookup.

CHA-545

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CHA-545

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
read_cached_index_sidecar claimed a sidecar and a base segment "never collide
in one cache"; SegmentCache's key-space paragraph argues the opposite — that a
same-content collision between two artifact classes is the correct answer
rather than a bug. The cache's framing is the sound one, and the "never
collide" claim was both stronger than needed and unenforced (it happens to
hold because a sidecar's schema is (key_0..key_n-1, row_offset), but nothing
checks that). Point at SegmentCache instead of restating a second, disagreeing
invariant.

CHA-545

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Folding the storage format into the cache key pushed
`decode_and_cache_native` to 8 parameters, one over clippy's
`too_many_arguments` threshold, failing `just check`. Rather than
silence the lint, give the pair a name: `SegmentCacheKey` is what
`SegmentCache` is keyed by, so the two halves travel together through
the driver instead of as adjacent positional arguments a caller could
transpose.

CHA-545
The read-time schema question is the one a reader of this cache asks
first, and neither the ADR nor the type doc answered it: they said the
value must be the file-native decode without saying that read-time
shaping still happens, just after the lookup. That reads as if read-time
schema had been rejected outright, when only the decode is
segment-scoped -- because only the decode is shared.

So state the mechanics: a worked parent/fork example over one shared
slice, and the symmetric design that fingerprints the read schema into
the key. Evaluate that one at its strongest -- caching the type-cast
table, projection still per caller -- since the weaker variant that
caches the fully shaped table loses to an avoidable problem and is not
the real comparison. The strong form is implementable and correct; it is
rejected because its key moves whenever the schema moves while the data
does not, so one ADD COLUMN re-fingerprints a footprint nobody rewrote
and a fork stops sharing with its parent at their first divergent ALTER,
which is the case this key exists for. Its one real advantage -- serving
a read whose declared type differs from the file's -- has nothing to
bite on while schema evolution is ADD COLUMN only, and if that changes
the cast belongs in the shaping tail where it is per caller and visible.

Also record what actually directs the decode today. Both readers take
the schema the format engine embedded, nothing in penca-format casts,
and the table's stored point-in-time `arrow_schema` is never consulted
-- which is precisely why `format` sits in the cache key. Directing the
decode from the stored schema instead would make it droppable; logged as
an open question rather than built here.

CHA-545
e7e68c7 paired the storage format with content_hash on the argument that the
cached file-native decode is a per-format artifact — a round trip may widen a
type or re-dictionary-encode. That inverts the contract. Writing an Arrow batch
and reading it back is supposed to return that batch, so Parquet and Lance
decoding one content_hash differently would mean one of them is wrong, not that
the cache should model the difference.

Nothing asserts the contract today (format_reader.rs checks row and column
counts through the shaped path only), but keying around an unverified defect
costs a real cross-format share permanently and would mask the defect if it
existed — the cross-format type mismatch is exactly the signal that should be
loud. CHA-548 adds round-trip identity coverage driven from CanonicalType.

SegmentCacheKey is deleted; the key is Uuid again. Drops
same_hash_under_two_formats_does_not_share_an_entry, which asserted the
behavior being removed.

CHA-545

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nhobin219
nhobin219 force-pushed the nhobin219/cha-545-segment-cache-content-hash branch from ac41149 to 2a6cd96 Compare July 31, 2026 19:34
Nico Bautista Hobin added 2 commits July 31, 2026 20:07
arrow-ipc emits a view array's variadic data buffers whole rather than
prove no surviving view points into a pruned one, so a digest of a slice
encoded the whole parent's string payload. `compact` and the snapshot
packer both hash once per slice of one `concat_batches` result, so a wave
of N inputs re-serialized every input's bytes N times — on exactly the
many-small-segments workload compaction exists for.

`gc` the byte-view columns first, descending one level into a list child
since `CanonicalType` admits views there and rejects deeper nesting. That
bounds each digest to its own rows and makes it canonical for these types:
two segments holding the same values now agree even when their parents
laid those values out differently, which is the dedup the digest exists
for. The contract loses its slice-invariance exception.

CHA-545
`FixedSizeListArray::try_new` re-derives the length from the null buffer
when `size == 0`, which is zero for a null-free column — and
`CanonicalType` accepts size 0. Rebuilding through it collapsed such a
column to zero rows, so two segments of different row counts would digest
identically: a false dedup match on the one value that is supposed to be
identity. `try_new_with_length` carries the length across.

Also covers the compaction branches the first pass left untested —
`BinaryView`, `LargeList`, and `FixedSizeList`, the last with a guard
pinning the `slice`-narrows-the-child assumption its arm rests on.

CHA-545
@nhobin219
nhobin219 added this pull request to the merge queue Jul 31, 2026
Merged via the queue into main with commit 5857ef2 Jul 31, 2026
14 checks passed
@nhobin219
nhobin219 deleted the nhobin219/cha-545-segment-cache-content-hash branch August 1, 2026 00:29
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.

1 participant