ADR-010: Iceberg V3 Writes Must Be the Spec, and a Foreign Reader Is the Acceptance Test¶
Status: accepted (
v0.16.0). Implemented acrosspuffin.rs,iceberg/writer.rs,iceberg/manifest.rs,iceberg/delete.rs,manifest/types.rs,metadata.rs,table/maintenance.rs, andmerge.rs. Change history: see the[Unreleased]/0.16.0entries inCHANGELOG.md. Catalog-side decisions from the same programme are inADR-009.
1. Context¶
BenoStreamDB’s portability claim rests on writing ordinary Apache Iceberg metadata: v{N}.metadata.json, Avro manifest lists, Avro data/delete manifests, Parquet segments, Puffin sidecars. Two forces made a decision necessary.
Force 1 — a private dialect is a silent data-loss risk. The engine had begun encoding V3 concepts in shapes only it understood: the manifest Avro metadata hardcoded format-version = "2"; the deletion-vector Puffin blob used a foreign type identifier (apache-datasketches-theta-v1) with a raw roaring payload; delete entries used a custom content = 3 code; and the V3 manifest schema never emitted fields 143–145. A foreign engine opening such a table has two choices and both are bad: reject it, or trust the metadata and return rows the writer deleted.
Force 2 — the existing verification could not detect this class of bug. PyIceberg 0.12 is V2-centric, so it validates the V2 surface and ignores V3 semantics. Worse, BenoStreamDB’s own reader applies every deletion vector attached to a manifest entry and never consults referenced_data_file, so it is structurally incapable of noticing a vector that no external engine can match. “Our reader agrees with our writer” is not evidence.
2. Decision¶
D1 — Write the published V3 spec, never a private encoding. Deletion vectors use blob type deletion-vector-v1 and the spec envelope (4-byte big-endian length, magic D1 D3 39 64, portable 64-bit roaring bitmap, 4-byte big-endian CRC-32). Manifest metadata carries the table’s own format-version. Delete entries use content = 1 (POSITION_DELETES) with file_format = "PUFFIN" and populated referenced_data_file / content_offset / content_size_in_bytes.
Rejected: keeping the custom content = 3 encoding and documenting it. It is strictly less work, but it makes every V3 table engine-specific, which is the one thing an open table format exists to prevent. The legacy code is still accepted on read so tables written by earlier builds keep resolving.
D2 — V2 is frozen, not migrated. Every V3 behavior is gated on format_version >= 3. V2 tables keep Parquet position deletes and their manifests still declare format-version = 2.
Rejected: silently upgrading tables on write. It would change the delete representation under operators who never asked for it, and V2 remains the interchange format most clusters actually accept.
D3 — A foreign reader is the acceptance criterion, and it must not share code with the engine. Conformance means apache/iceberg-rust, Spark, or Trino opens the table and sees the exact row set. The Rust oracle is a separate workspace that depends on iceberg only; it communicates with the engine exclusively through on-disk artifacts.
Rejected: asserting against PyIceberg, or against our own reader. Both already passed while all three of the defects listed in §3 were live. Code sharing is what made them invisible.
D4 — Reject rather than repair. A deletion vector with a bad length, magic, or CRC is a typed error (BenoStreamError::DeletionVectorDeserialize), not a warning with a best-effort bitmap.
Rejected: tolerating malformed vectors. A silently-short delete list resurrects deleted rows; a failed query is the better failure.
3. Consequences¶
Positive. A format-version = 3 table written by BenoStreamDB is readable by iceberg-rust 0.11 and by Spark/Trino, including after deletions. V3 lineage is publishable: each snapshot carries a row range so a reader can assign stable _row_id values. The engine’s delete representation is now the interchange format rather than a local convention.
Negative and accepted.
CRC-32 is hand-rolled (IEEE 802.3, ~15 lines) instead of adding a dependency, because
deny.tomlaudits every crate in the graph.Positions above
u32::MAXare an error, not a 64-bit position. The engine’s row positions areu32; a table with more than 4 billion rows in one data file is out of scope, and the failure is loud.Table::write_mode()stays"buffered"on V3. Distributed Spark/Trino task staging cannot reserve disjoint_row_idranges without driver-side allocation. Safety comes fromADR-008’sPendingCommitmachine, not from a staged-write contract._row_idis not incurrent_schema(). It is a metadata column by spec; lineage lives innext-row-idand the snapshot row range. Tooling that expects toSELECT _row_idwill not find it there.Runtime pinning is bounded by what Iceberg publishes.
iceberg-spark-runtimeexists only for Spark minor series 3.4, 3.5, 4.0 and 4.1 — there is no 4.2 artifact, so Spark 4.2 has no supported pairing. The JVM matrix therefore pins Spark 3.5.2 (3.5_2.12, Java 17) and 4.0.x (4.0_2.13, Java 21) on Iceberg 1.11; 1.6 predates V3 vectors.The JVM matrix runs weekly, not per-PR. A Spark submit is a JVM start plus a ~200 MB Maven resolve. The trade is accepted because the format only moves when
src/core/iceberg/**ormetadata.rschanges, which the workflow’s path filter watches.
Risk this decision accepts. A bug in the V3 codec is now a bug in a format other engines depend on; there is no longer a private encoding to fall back to. The mitigation is the three independent readers in §4, not the engine’s own test suite.
4. How the Decision Is Enforced¶
Gate |
Enforces |
Cadence |
|---|---|---|
D1, D2 — manifest metadata/schema shape, parsed by a hand-written Avro container reader rather than |
every push |
|
D3 — |
every push |
|
D3 — Spark 3.5/4.0 and Trino 482/484 read the table, including V3 lineage columns |
weekly + on table-format changes |
|
|
D4 — round-trip plus rejection of bad length, magic, and CRC |
every push |
This ADR should be revisited if: Iceberg publishes a Spark runtime for a new minor series (re-pin the matrix), apache/iceberg-rust starts matching vectors by a different key (re-check D1’s path normalization), or the engine gains 64-bit row positions (D4’s u32 bound must be lifted).