CHA-545 | key the cold segment cache by content hash, not row uuid - #39
Conversation
nhobin219
left a comment
There was a problem hiding this comment.
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.insertin the whole workspace (crates/penca-dl/src/driver.rs:236, insidedecode_and_cache_native), and it always storesreader.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_toopins exactly the branch a regression would hide in. take_matched_rowstakes matched rows before shaping, keeping the seek path O(matches) rather than O(segment); guarded byseek_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 inpacker.rs::flush/single_partition_file, the empty-merge placeholder, and the sidecar batch thatwrite_segment_indexactually writes. compact.rscorrectly 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_batchesnormalizes to onesegment_schema, so a slice can decode wider than its input file) is the right reason.- Every reference copy inherits
old.content_hashverbatim:fork_copy.rs×3 (snapshot segments, sidecars, persist segments), plus both CHA-531 carry-forwards insnapshot.rsandsegment_index.rs. No path mints a fresh hash on a copy. - No cold write path into the three tables omits
content_hash. The remainingpenca-storage-metaINSERTs 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 feedColdStorageClient::read_persist_segments, which goes throughread_one_persist_segment— notread_cached_persist_segment.provider.rs:233is the only production cache-read construction and its segments come frommeta_plan.rs, built field-by-field off aNOT NULLcolumn. Theplan.rscomment defending theDefaultis accurate. - Weight accounting did not regress.
admits()and the weigher are unchanged and still chargesize_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-apihas nodatafusiondependency and nouse datafusion. All 24 commit headlines are conventional with in-repo scopes (cold,schema,query).
Strengths
- The
read_segment_native/shape_to_schemasplit is the right factoring, not a workaround:read_in_file_schemais shared by both arms inparquet.rsandlance.rs, and theconcat_batchesanchor correctly moved tobatches[0].schema()(file types) rather than the caller's. crates/penca-core/src/digest.rsdocuments 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_sidecardeliberately uses a non-Optionrow.get::<Uuid, _>("sidecar_content_hash")with a comment saying whyunwrap_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_hashacross both the persist and snapshot tiers and raises ifshared == 0, so the test can't silently degrade into a tautology when the fork stops sharing slices. IndexSidecardeliberately having noDefault(unlike the two segment types) is a well-reasoned asymmetry, not an oversight.
| /// 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. |
There was a problem hiding this comment.
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:
formatis intentionally absent from the key — it is functionally determined by the uuid, and every value is a format-agnostic decodedRecordBatch
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_schema → RecordBatch::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)withformat.as_wire_code()as the second component, or - fold the wire code into
segment_content_hashso the key stays a plainUuid.
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.
| /// 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. |
There was a problem hiding this comment.
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.
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>
ac41149 to
2a6cd96
Compare
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
Closes CHA-545.
The problem
SegmentCachewas keyed by the cold-artifact row'suuid. Reference copies minta new row uuid over an unchanged
(object_uri, offset, length):insert_carried_snapshot_segments/insert_carried_segment_indexescarry asnapshot forward (CHA-531), and
fork_copymaterializes 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: anxxh3_128digest ofthe typed in-memory Arrow batch, computed once at write time
(
penca_core::digest::segment_content_hash).SegmentCacheis keyed by thathash, 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_hashverbatim, so when a forkALTERsa 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-shapedentry 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_sliceis theregression 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_FORMAThas been flipped between two writes of thesame 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);2a6cd96reverses 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 thecontract 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 COLUMNre-fingerprints a footprint nobody rewrote, and a fork stops sharingwith its parent at their first divergent
ALTER— which is the dedup this PRexists 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 shapingtail where it is per-caller and visible, not hidden behind a cache key.
docs/design-decisions.mdcarries 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
SegmentCacheand are reference-copied by thesame two mechanisms.
content_hashisUUID NOT NULLwith no default on all three tables. Everywriter 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_metadatadeliberately has nocontent_hash: it is never cache-read and never reference-copied.Commits
Red tests
0d0a9fetest(cold)— failing cross-branch segment cache dedup tests1343c25test(cold)— cross-branch schema-divergence regression guardDigest + schema
401f38efeat(cold)—segment_content_hashover typed Arrow batches0bd3200fix(cold)— drop the no-op normalization, pin dictionary handling50f177dfeat(schema)—content_hashcolumn on segment + sidecar metadataWrite + copy paths
8b1482cfeat(cold)— computesegment_content_hashat every fresh cold write3dddb34feat(cold)— inheritcontent_hashon every reference copyRead path
f244986refactor(query)— cache the file-native decode, shape after the lookup75cb567feat(query)— keySegmentCachebycontent_hash6435833perf(query)— take matched rows before shaping on the index-seek pathCleanup pass (
orch:run-cleanup)f401463refactor(cold)— keySegmentCachebyUuid, not a stringified hash7043699refactor(cold)— extractrequested_columnsfrom the format readerse1fe2e5refactor(cold)— name the projection-less shaping call in the driver6687637refactor(cold)— collapse three decode-and-cache copies into one helperec961b6feat(cold)— report the sidecar cache miss as cached vs uncachedPlus test-fixture and docs commits (
bb60e6e,55102b6,48e6eaa,b4b1539,b8310a2,1a16aab,3fd2cef,b1c4ac8,1b7736f).Review follow-ups (
/review-pr, folded in rather than deferred)6d755e2fix(cold)— fold the storage format into the segment cache key97ad00achore— cargo fmt the segment cache key call siteb2100a4docs(cold)— reconcile the sidecar collision note with the key space7308732refactor(cold)— name the segment cache key as a typea3ae1bddocs(cold)— record why the cache holds the native decode2a6cd96refactor(cold)— drop the format from the segment cache key(reverses
6d755e2; see the section above)2b57ac7perf(cold)— compact byte-view buffers before hashing a segmenta78f281fix(cold)— keep the row count when compacting a zero-size fixed listThose 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
compactand the snapshot packer both hash once per slice of one
concat_batchesresult,so a wave of
Ninputs re-serialized every input's bytesNtimes, on exactlythe many-small-segments workload compaction exists for.
gc-ing the byte-viewcolumns first (descending one level into a list child, which is as deep as
CanonicalTypeallows a view to sit) bounds each digest to its own rows andmakes 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.
a78f281fixes a bug that compaction introduced:FixedSizeListArray::try_newdoes not preserve its input's length — at
size == 0it re-deriveslenoff thenull buffer, so a null-free
FixedSizeList(Utf8View, 0)rebuilt as zero rowsand two such columns digested identically regardless of row count.
CanonicalTypeaccepts any
i32size including0, so nothing upstream rejects it.try_new_with_lengthcarries the length through. The same commit covers theBinaryView,LargeList<view>andFixedSizeList<view>compaction branches,which the first pass left untested — including a guard pinning the arrow behavior
the
FixedSizeListarm rests on (slicenarrows its child, unlike the offsetflavours), 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.rshalfintact: all three copy statements (
table_persist_segment_metadata,segment_index_metadata,snapshot_segment_metadata) selectold.content_hashverbatim. No
TODO(CHA-545)is left anywhere in the tree.#37 (CHA-546). Merged during review; rebased onto it, conflicting in
fork_copy.rsandpersist.rswhere both changes edit the same SQL. #37 madethe copy statements name the parent and child partitions separately
(
FROM {seg_old} old) and bind the already-parsed branch uuid; this PR addsold.content_hashto the same SELECT lists and acontent_hashbind torepoint_table_persist_segment. Both halves are kept in each of the fourresolutions.
Out of scope
Round-trip identity coverage. CHA-548 — write a fixture batch per
CanonicalTypevariant through eachFormatWriter, read it back viaread_segment_native, assert schema and data equality, and assert the twoformats agree. That is the invariant this PR's key assumes;
penca-formatis theone consumer that never matches on
CanonicalType, so CHA-386's cross-crateexhaustiveness 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 underbuilder.schema()and Lanceunder
reader.metadata().file_schema, and nothing inpenca-formatcasts. Thatpredates this PR — old
read_segmentand newread_segment_nativeshare thesame
read_in_file_schema. Directing the decode from the stored schema wouldmake 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-branchschema-divergence guard (
1343c25). The latter is the one that forced thenative-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 treejust cargo-clippy— clean on the final tree--profile=teststack, against thefinal 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 isthe 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 oncemore 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 afake segment row from an explicit column list that predated
content_hash, andthe new
NOT NULLbroke it. No other white-box insert touches an affected table— the
purge_tx_logones writetx_log_persist_segment_metadata, whichdeliberately has no
content_hash.🤖 Generated with Claude Code