feat: add history metadata table#2825
Conversation
laskoviymishka
left a comment
There was a problem hiding this comment.
This is a clean addition — it follows the snapshots/manifests sibling pattern closely, and the parts that matter for correctness line up with Java exactly: schema field ids, names, required/optional, the parent_id semantics, and made_current_at as µs timestamptz. The parent-cycle guard on the ancestor walk is a nice touch too.
Nothing here is blocking before merge. The one I care about most: that cycle guard is deliberate, but no test exercises it, so a future refactor could drop it and everything stays green. And the ancestor walk duplicates crate::util::snapshot::ancestors_of with a real divergence (the util has no cycle guard) so we should decide whether to reuse it or document why it's inlined.
A couple other smaller things:
- Lock the cycle guard in with a parent-cycle metadata test asserting
scan()terminates. - Reuse
ancestors_of(pushing the guard down into it) or comment why the walk is inlined. - A couple of test gaps: an empty snapshot-log (zero-row batch — that's historically where Arrow schema mismatches surface) and a no-current-snapshot case (
current-snapshot-id: -1→ allis_current_ancestorfalse). - Minor consistency, all optional:
UTC_TIME_ZONEinstead of the"+00:00"literal, field-vs-method access onentry, and a checked multiply ontimestamp_ms * 1000.
Most of the small stuff is shared debt with the sibling tables, so no strong feelings there. Will wait @CTTY before merge.
And thanks for this amazing contribution!
| // is what tells the live lineage apart from rolled-back history entries. | ||
| let mut ancestors = HashSet::new(); | ||
| let mut snapshot = metadata.current_snapshot(); | ||
| while let Some(ancestor) = snapshot { |
There was a problem hiding this comment.
This ancestor walk is basically crate::util::snapshot::ancestors_of (util/snapshot.rs:38) inlined — except that one has no cycle guard and this one does.
I'd rather not maintain two copies of the traversal that disagree on a safety property. Could we push the cycle guard down into ancestors_of and reuse it here? If there's a real API mismatch (&TableMetadata vs &TableMetadataRef) that makes reuse awkward, a one-line comment noting why it's inlined would do. wdyt?
| let mut ancestors = HashSet::new(); | ||
| let mut snapshot = metadata.current_snapshot(); | ||
| while let Some(ancestor) = snapshot { | ||
| // corrupt metadata with a parent cycle must not hang the scan |
There was a problem hiding this comment.
This guard is the one thing here that isn't exercised by the tests, and it's exactly the kind of thing a refactor removes silently. I'd add a metadata fixture with a parent cycle and assert scan() terminates, so the guard is actually locked in.
| let mut is_current_ancestor = BooleanBuilder::new(); | ||
|
|
||
| for entry in metadata.history() { | ||
| made_current_at.append_value(entry.timestamp_ms() * 1000); // ms -> µs |
There was a problem hiding this comment.
timestamp_ms() * 1000 is an unchecked i64 multiply — it'll wrap for pre-epoch or huge values. snapshots.rs:112 has the same thing, so this isn't a regression, but a saturating/checked multiply would be more defensive if you're touching it anyway.
|
|
||
| for entry in metadata.history() { | ||
| made_current_at.append_value(entry.timestamp_ms() * 1000); // ms -> µs | ||
| snapshot_id.append_value(entry.snapshot_id); |
There was a problem hiding this comment.
Small consistency thing: entry.timestamp_ms() uses the method one line up and entry.snapshot_id uses the field here. SnapshotLog exposes both, so it works, but reading them back to back it's a little jarring. I'd use field access for both — entry.timestamp_ms.
| } | ||
|
|
||
| let mut made_current_at = | ||
| PrimitiveBuilder::<TimestampMicrosecondType>::new().with_timezone("+00:00"); |
There was a problem hiding this comment.
There's a crate::arrow::UTC_TIME_ZONE constant (arrow/schema.rs:45) for exactly this. snapshots.rs:86 also hard-codes the string, so this'd be the second occurrence — I'd use the constant here rather than grow the string literals.
| // Walk the current snapshot's parent chain once: membership in this set | ||
| // is what tells the live lineage apart from rolled-back history entries. | ||
| let mut ancestors = HashSet::new(); | ||
| let mut snapshot = metadata.current_snapshot(); |
There was a problem hiding this comment.
Heads up that current_snapshot() has a hidden .expect() (table_metadata.rs:315) that panics if current_snapshot_id is set but the snapshot is missing from the map — corrupt or GC'd metadata. validate_refs probably makes that unreachable for a well-formed Table, so I don't think it needs code here, but a short comment noting we rely on that invariant would save the next reader the trip. (Java's SnapshotUtil returns null instead of panicking, fwiw.)
|
Thanks for the thorough review! All addressed in 5c544c4:
|
Add an inspect subsystem (Table.Inspect) and its first metadata table, History, mirroring the Java, PyIceberg, and Rust clients (apache/iceberg-rust#2825). History exposes the snapshot log as an Arrow RecordReader with columns made_current_at (timestamp[us,UTC]), snapshot_id, parent_id, and is_current_ancestor (field IDs 1-4 for cross-client parity). Ancestry is computed by walking the current snapshot's parent chain via the existing AncestorsOf helper (cycle-guarded); rolled-back snapshots are flagged non-ancestors and expired snapshots render a null parent_id. The context is honored during iteration and errors are wrapped.
Which issue does this PR close?
Part of #823 (metadata tables tracking issue). Follows the pattern of the existing
snapshotsandmanifeststables.What changes are included in this PR?
Adds the
historymetadata table, mirroring Java'sHistoryTable:HistoryTableiniceberg::inspectwith the Java schema exactly (field ids 1–4:made_current_attimestamptz,snapshot_id, nullableparent_id,is_current_ancestor).is_current_ancestoris computed by walking the current snapshot's parent chain — rolled-back snapshots stay visible in the log but are flaggedfalse, matchingSnapshotUtil.currentAncestorIdssemantics.parent_idis null when the entry's snapshot has been expired, matching Java's null guard.MetadataTableTypeand the DataFusion provider (table$history).Are these changes tested?
test_history_table_with_rolled_back_snapshot: a table rolled back from S2 and re-committed as S3 (parent S1) — asserts S2 remains in history withis_current_ancestor = falsewhile S1/S3 staytrue. This lineage divergence is what distinguisheshistoryfromsnapshots.parent_id,falseancestor flag) and a snapshot appearing twice in the log (one row per entry).test_history_table: schema + full-row assertions over the shared fixture.SHOW TABLESsqllogictest updated;public-api.txtregenerated.