Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0d0a9fe
test(cold): add failing cross-branch segment cache dedup tests
Jul 31, 2026
401f38e
feat(cold): add segment_content_hash digest over typed Arrow batches
Jul 31, 2026
3fd2cef
docs(cold): correct the slice-invariance note in segment_content_hash
Jul 31, 2026
50f177d
feat(schema): content_hash column on segment + index-sidecar metadata
Jul 31, 2026
bb60e6e
test(cold): derive distinct content_hash values for cache fixtures
Jul 31, 2026
0bd3200
fix(cold): drop the no-op digest normalization, pin dictionary handling
Jul 31, 2026
55102b6
test(cold): give multi-segment dl fixtures distinct content hashes
Jul 31, 2026
48e6eaa
docs(cold): correct the supported set in segment_content_hash's contract
Jul 31, 2026
1343c25
test(cold): restore the cross-branch schema-divergence regression guard
Jul 31, 2026
8b1482c
feat(cold): compute segment_content_hash at every fresh cold write
Jul 31, 2026
3dddb34
feat(cold): inherit content_hash on every reference copy
Jul 31, 2026
b4b1539
docs(cold): scope the view-slice exception to the parent array
Jul 31, 2026
b8310a2
test(cold): join the fork-divergence precondition on content_hash
Jul 31, 2026
f244986
refactor(query): cache the file-native decode, shape after the lookup
Jul 31, 2026
75cb567
feat(query): key SegmentCache by content_hash
Jul 31, 2026
6435833
perf(query): take matched rows before shaping on the index-seek path
Jul 31, 2026
1a16aab
docs(cold): record the segment-cache content-hash decision
Jul 31, 2026
f401463
refactor(cold): key SegmentCache by Uuid instead of a stringified hash
Jul 31, 2026
7043699
refactor(cold): extract requested_columns from the format readers
Jul 31, 2026
b1c4ac8
docs(cold): correct what the content hash does and does not separate
Jul 31, 2026
e1fe2e5
refactor(cold): name the projection-less shaping call in the driver
Jul 31, 2026
6687637
refactor(cold): collapse three decode-and-cache copies into one helper
Jul 31, 2026
ec961b6
feat(cold): report the sidecar cache miss as cached vs uncached
Jul 31, 2026
1b7736f
test(cold): supply content_hash in the fake persist-segment insert
Jul 31, 2026
6d755e2
fix(cold): fold the storage format into the segment cache key
Jul 31, 2026
97ad00a
chore: cargo fmt the segment cache key call site
Jul 31, 2026
b2100a4
docs(cold): reconcile the sidecar collision note with the key space
Jul 31, 2026
7308732
refactor(cold): name the segment cache key as a type
Jul 31, 2026
a3ae1bd
docs(cold): record why the cache holds the native decode
Jul 31, 2026
2a6cd96
refactor(cold): drop the format from the segment cache key
Jul 31, 2026
2b57ac7
perf(cold): compact byte-view buffers before hashing a segment
Jul 31, 2026
a78f281
fix(cold): keep the row count when compacting a zero-size fixed list
Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/penca-api/src/lifecycle/compact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ impl<'a> PersistScope<'a> {
// honoring the ceiling AND the slice arithmetic has to be reworked
// for short reads.
max_commit_seq_num: Some(row.get("max_commit_seq_num")),
content_hash: row.get("content_hash"),
})
}
}
Expand Down Expand Up @@ -320,6 +321,15 @@ where
} else {
0
};
// Recomputed per slice rather than carried over from the input row.
// The rows are preserved 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 hash
// would leave two different decodes under one cache key.
let slice_hash = penca_core::digest::segment_content_hash(
&merged.slice(cumulative as usize, meta.row_count as usize),
)
.map_err(ApiError::Arrow)?;
LifecycleManager::repoint_table_persist_segment(
&tx,
&catalog_str,
Expand All @@ -331,6 +341,7 @@ where
proportional_size,
storage_format_text,
&merged_stats,
&slice_hash,
false,
)
.await?;
Expand Down
20 changes: 17 additions & 3 deletions crates/penca-api/src/lifecycle/durable_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use penca_db::driver::pg::PgDriver;
use penca_format::writer::FormatWriter;
use penca_storage_cold::ColdStorageClient;
use penca_storage_meta::LifecycleManager;
use uuid::Uuid;

use crate::error::ApiError;

Expand Down Expand Up @@ -219,6 +220,12 @@ impl<'a> SegmentScope for PersistSegmentScope<'a> {

async fn insert_segment(&self, pool: &PgDriver, step: &Self::Step) -> Result<(), ApiError> {
let statistics = penca_dl::stats::compute_segment_statistics(&step.batch);
// Hashed here, on the in-memory batch, rather than after `write_file`:
// the digest identifies the logical content handed to the format writer,
// which is what the segment cache stores post-decode. It is deliberately
// not a checksum of the object on S3 — see `segment_content_hash`.
let content_hash =
penca_core::digest::segment_content_hash(&step.batch).map_err(ApiError::Arrow)?;
LifecycleManager::insert_table_persist_segment(
pool,
self.catalog_str,
Expand All @@ -235,6 +242,7 @@ impl<'a> SegmentScope for PersistSegmentScope<'a> {
step.num_rows,
self.storage_format.extension(),
&statistics,
&content_hash,
)
.await?;
Ok(())
Expand Down Expand Up @@ -324,6 +332,8 @@ impl<'a> SegmentScope for SnapshotSegmentScope<'a> {

async fn insert_segment(&self, pool: &PgDriver, step: &Self::Step) -> Result<(), ApiError> {
let statistics = penca_dl::stats::compute_segment_statistics(&step.batch);
let content_hash =
penca_core::digest::segment_content_hash(&step.batch).map_err(ApiError::Arrow)?;
LifecycleManager::insert_snapshot_segment(
pool,
self.catalog_str,
Expand All @@ -341,6 +351,7 @@ impl<'a> SegmentScope for SnapshotSegmentScope<'a> {
step.num_rows,
self.storage_format.extension(),
&statistics,
&content_hash,
)
.await?;
Ok(())
Expand Down Expand Up @@ -409,9 +420,10 @@ pub(super) struct SnapshotFileStep {
/// One per-partition segment metadata row inside a packed file.
/// `offset`/`length` are the partition's row range within the file —
/// `length` doubles as the catalog `row_count` (a packed row IS its
/// row range; both columns are NOT NULL). `size_bytes`
/// and `statistics` are computed over the slice only, so pruning
/// stats stay partition-tight.
/// row range; both columns are NOT NULL). `size_bytes`,
/// `statistics` and `content_hash` are computed over the slice only, so
/// pruning stats stay partition-tight and the hash identifies the rows
/// this one row covers rather than the whole packed file.
pub(super) struct SnapshotSegmentRowSpec {
pub seg_uuid_str: String,
pub chunk_idx: u32,
Expand All @@ -425,6 +437,7 @@ pub(super) struct SnapshotSegmentRowSpec {
pub length: i64,
pub size_bytes: i64,
pub statistics: Vec<u8>,
pub content_hash: Uuid,
}

impl<'a> DurableSegmentWriter<SnapshotSegmentScope<'a>> {
Expand Down Expand Up @@ -456,6 +469,7 @@ impl<'a> DurableSegmentWriter<SnapshotSegmentScope<'a>> {
row.length,
self.scope.storage_format.extension(),
&row.statistics,
&row.content_hash,
)
.await?;
self.current_group()
Expand Down
11 changes: 8 additions & 3 deletions crates/penca-api/src/lifecycle/packer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,10 @@ impl SegmentPacker {
}

/// Concatenate the buffered partitions into one file step with one
/// segment row per partition. `size_bytes` and `statistics` are
/// computed per partition slice, not per file, so pruning stats
/// stay partition-tight.
/// segment row per partition. `size_bytes`, `statistics` and
/// `content_hash` are computed per partition slice, not per file, so
/// pruning stats stay partition-tight and each row's hash identifies
/// its own rows rather than the packed file they share.
fn flush(&mut self) -> Result<Option<SnapshotFileStep>, ApiError> {
if self.buffered.is_empty() {
return Ok(None);
Expand All @@ -297,6 +298,8 @@ impl SegmentPacker {
length: num_rows,
size_bytes: batch_in_memory_bytes(batch)?,
statistics: penca_dl::stats::compute_segment_statistics(batch),
content_hash: penca_core::digest::segment_content_hash(batch)
.map_err(ApiError::Arrow)?,
});
self.chunk_idx += 1;
offset += num_rows;
Expand Down Expand Up @@ -327,6 +330,8 @@ impl SegmentPacker {
length: num_rows,
size_bytes: chunk_bytes,
statistics: penca_dl::stats::compute_segment_statistics(&chunk),
content_hash: penca_core::digest::segment_content_hash(&chunk)
.map_err(ApiError::Arrow)?,
};
self.chunk_idx += 1;
Ok(SnapshotFileStep {
Expand Down
17 changes: 13 additions & 4 deletions crates/penca-api/src/lifecycle/snapshot_op.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ fn empty_merge_placeholder_step(
base_uri: &str,
storage_format_text: &str,
user_schema: &SchemaRef,
) -> SnapshotFileStep {
) -> Result<SnapshotFileStep, ApiError> {
let placeholder_batch = RecordBatch::new_empty(penca_merge::snapshot_read_schema(user_schema));
let seg_uuid = table_snapshot_segment_uuid(snap_uuid, 0);
let uri = snapshot_segment_uri(
Expand All @@ -94,7 +94,11 @@ fn empty_merge_placeholder_step(
storage_format_text,
);
let statistics = penca_dl::stats::compute_segment_statistics(&placeholder_batch);
SnapshotFileStep {
// Every empty placeholder over one schema is the same zero-row content, so
// they legitimately collapse onto one cache entry.
let content_hash =
penca_core::digest::segment_content_hash(&placeholder_batch).map_err(ApiError::Arrow)?;
Ok(SnapshotFileStep {
snap_uuid_str: snap_str.to_string(),
uri,
file_batch: placeholder_batch,
Expand All @@ -106,8 +110,9 @@ fn empty_merge_placeholder_step(
length: 0,
size_bytes: 0,
statistics,
content_hash,
}],
}
})
}

impl LifecycleManager {
Expand Down Expand Up @@ -1539,7 +1544,7 @@ impl LifecycleManager {
&self.base_uri,
ctx.storage_format_text,
ctx.user_schema,
);
)?;
seg_writer
.write_segment_group(pool, writer, &placeholder)
.await?;
Expand Down Expand Up @@ -1912,6 +1917,10 @@ async fn build_one_segment_sidecar<W: FormatWriter>(
// sorted the keys, so the bounds are the first/last entry). Deferred so
// the seek owns the exact bound encoding rather than guessing it here.
&[],
// The sidecar's own content, not the base segment's: two segments with
// different rows can build byte-identical indexes, and it is the index
// batch that this row's cache entry holds.
&penca_core::digest::segment_content_hash(&sidecar).map_err(ApiError::Arrow)?,
)
.await?;
built_uris.push(uri);
Expand Down
22 changes: 16 additions & 6 deletions crates/penca-api/src/query/meta_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,7 @@ impl QueryManager {
};
let snapshot_sql = format!(
"SELECT seg.table_snapshot_segment_uuid, seg.object_uri, \
seg.\"offset\", seg.length, seg.format, \
seg.\"offset\", seg.length, seg.format, seg.content_hash, \
snap.snapshotted_at_micros, snap.commit_seq_num, \
seg.table_snapshot_uuid, seg.row_count, \
seg.size_bytes, seg.metadata, seg.statistics, \
Expand All @@ -794,7 +794,8 @@ impl QueryManager {
c.object_uri AS sidecar_object_uri, \
c.\"offset\" AS sidecar_offset, c.length AS sidecar_length, \
c.format AS sidecar_format, c.size_bytes AS sidecar_size_bytes, \
c.segment_index_uuid AS sidecar_segment_index_uuid \
c.segment_index_uuid AS sidecar_segment_index_uuid, \
c.content_hash AS sidecar_content_hash \
FROM {seg_table} seg \
INNER JOIN {snap_table} snap \
ON seg.table_snapshot_uuid = snap.table_snapshot_uuid \
Expand Down Expand Up @@ -909,6 +910,7 @@ impl QueryManager {
size_bytes: row.get("size_bytes"),
metadata_json: metadata.to_string(),
statistics: statistics.unwrap_or_default(),
content_hash: row.get("content_hash"),
row_uuid_index_sidecar: None,
index_sidecars: Vec::new(),
});
Expand Down Expand Up @@ -1264,7 +1266,7 @@ impl QueryManager {
"SELECT tfm.log_kind, \
seg.table_persist_segment_uuid AS segment_uuid, \
seg.object_uri, seg.\"offset\", seg.length, \
seg.row_count, seg.format, \
seg.row_count, seg.format, seg.content_hash, \
seg.min_tx_commit_micros, \
seg.max_tx_commit_micros, \
seg.max_commit_seq_num, \
Expand Down Expand Up @@ -1340,6 +1342,7 @@ impl QueryManager {
// segment this equals the file's true maximum, so the ceiling
// costs a no-op filter and needs no per-row special case.
max_commit_seq_num: Some(row.get("max_commit_seq_num")),
content_hash: row.get("content_hash"),
};

match log_kind {
Expand Down Expand Up @@ -1450,9 +1453,10 @@ pub(crate) struct HotTableNames {
/// Decode one row's LEFT-JOINed child-sidecar columns into an
/// [`IndexSidecar`], or `None` when no child matched (NULL
/// `sidecar_object_uri`). The object_uri-presence gate stands in for "the
/// whole child row matched": offset/length/format/segment_index_uuid are all
/// NOT NULL in the schema, so the non-Option `row.get`s cannot hit a NULL
/// while object_uri is present. Revisit if a nullable child column is added.
/// whole child row matched": offset/length/format/segment_index_uuid/
/// content_hash are all NOT NULL in the schema, so the non-Option `row.get`s
/// cannot hit a NULL while object_uri is present. Revisit if a nullable child
/// column is added.
fn decode_child_sidecar(row: &PgRow) -> Result<Option<IndexSidecar>> {
row.get::<Option<String>, _>("sidecar_object_uri")
.map(|object_uri| -> Result<IndexSidecar> {
Expand All @@ -1471,6 +1475,10 @@ fn decode_child_sidecar(row: &PgRow) -> Result<Option<IndexSidecar>> {
format: sidecar_format,
segment_index_uuid: row.get::<Uuid, _>("sidecar_segment_index_uuid").to_string(),
size_bytes: row.get::<Option<i64>, _>("sidecar_size_bytes").unwrap_or(0),
// Non-Option deliberately, unlike `size_bytes` directly above:
// an `unwrap_or(Uuid::nil())` here would hand every sidecar the
// same cache key and serve segment X's index for segment Y.
content_hash: row.get::<Uuid, _>("sidecar_content_hash"),
})
})
.transpose()
Expand Down Expand Up @@ -1785,6 +1793,7 @@ mod assemble_tests {
offset: None,
length: None,
max_commit_seq_num: None,
content_hash: penca_core::naming::deterministic_uuid_from(&[uuid]),
}
}

Expand Down Expand Up @@ -1956,6 +1965,7 @@ mod assemble_tests {
format: Format::Parquet,
segment_index_uuid: uri.to_string(),
size_bytes: 1,
content_hash: penca_core::naming::deterministic_uuid_from(&[uri]),
}
}

Expand Down
Loading
Loading