ADR-008: PendingCommit Transaction State Machine, Linearizable Reads, and Segmented WAL Recovery¶
Status: implemented (
v0.15.2, AuditM2). Implemented acrosssrc/core/table/mod.rs,write.rs,read.rs,stage.rs,maintenance.rs,builder.rs,src/core/wal.rs,src/core/metadata.rs,src/core/manifest/, andsrc/core/catalog/rest.rs. Certified bytests/test_m2_commit_state_machine.rs(Pillars 1–9),docs/PRODUCTION_READINESS_ASSESSMENT.md, anddocs/RECOVERY_RUNBOOK.md.
1. Context & Operational Motivation¶
BenoStreamDB eliminates the three-system ingestion tax — separate OLTP buffer database, Kafka-to-Iceberg micro-batch ETL, and standalone vector index — by buffering writes in an embedded write-ahead log (WAL) and committing directly to Parquet segments and Apache Iceberg snapshots inside a single Rust process. When flush/commit execution is interleaved with concurrent readers, concurrent buffered writers, process crashes, or multi-writer object-store contention, implicit state transitions between in-memory buffers and durable Iceberg metadata introduce five concrete failure modes:
Visibility Holes During Flush (
buffer-to-manifestHandoff): Drainingpending_writesandmemory_indexbefore the Iceberg manifest becomes durable makes committed-in-WAL rows temporarily invisible to concurrent SQL scans and ANN searches. Conversely, retaining those batches inpending_writesafter manifest commit causes duplicate row emission.Irrecoverable Pre-Manifest Flush Failures: If Parquet staging, object-store multipart upload, or Iceberg V3 row-lineage OCC fails after
pending_writeshas been drained, discarding or dropping the drained batches breaks the read-your-own-writes and retry guarantees on subsequentcommit_async()calls.WAL Truncation Races: Deleting every
.arrowfile under_wal/after a snapshot commit can drop newly appended transactions from concurrentwrite_async()calls that arrived while the flush was uploading Parquet files. Conversely, a single torn trailing frame afterSIGKILLorSIGABRTmust not quarantine valid prefix records in the same WAL segment.Version Hint & Catalog Convergence Races: Unconditional overwrites of
metadata/version-hint.textor single-shot REST catalog commits (POST /tables/...) allow delayed writers to regress reader metadata pointers or abort on HTTP 409 conflicts even when the underlying Parquet and manifest state already converged.Orphaned Data Files on V3 OCC Conflicts: Under Apache Iceberg Format Version 3, each writer assigns sequential
_row_idand_last_updated_sequence_numbervalues relative to the current manifest head. When two V3 writers race and one loses the manifest CAS, its staged Parquet files carry overlapping_row_idranges and must be deleted before retrying against the new head.
Before v0.15.2, these boundaries were handled implicitly across separate helper methods in write.rs. Extended production-readiness audit M2 replaced that implicit control flow with an explicit, inspectable state machine: PendingCommit.
2. Decision: Explicit PendingCommit State Machine¶
All buffered table commits execute under Table::commit_lock and record their lifecycle phase inside Table::pending_commit: Arc<parking_lot::Mutex<Option<PendingCommit>>> alongside a monotonically incremented buffer_epoch: Arc<AtomicU64> (Ordering::SeqCst).
flowchart LR
Idle["No PendingCommit (batches in pending_writes)"] -->|"flush_unlocked_async(): drain pending_writes + memory_index"| Staging["CommitPhase::Staging (batches + wal_tx_ids + frozen_memory_index readable)"]
Staging -->|"Pre-manifest error / WalRotate / DataUpload / ManifestCommit fault"| Restore["restore_staging_pending_commit_to_buffer() (prepends ahead of new pending_writes)"]
Restore --> Idle
Staging -->|"stage_and_commit_manifest_async() succeeds (manifest vN durable)"| Committed["CommitPhase::ManifestCommitted (committed_manifest stored, cluster_indexes cleared, buffer_epoch bumped)"]
Committed -->|"finish_pending_commit_tail_async(): TableMetadata CAS + optional Catalog CAS"| Published["CommitPhase::IcebergPublished (metadata_location + published_snapshot stored)"]
Published -->|"prune_committed_segments() + publish_committed() once + clear PendingCommit + bump buffer_epoch"| Idle
2.1 State Definitions & Lock Ordering¶
CommitPhase enforces strict phase ordering (PartialOrd, Ord):
Phase |
Authoritative Durability State |
In-Memory Reader View |
Failure / Retry Action |
|---|---|---|---|
|
Batches are durable in |
Readers scan committed manifest plus |
|
|
|
Readers load manifest |
Subsequent flush calls |
|
|
Same as |
Finishes remaining WAL pruning ( |
To eliminate lock-inversion deadlocks between concurrent writers, flush tasks, readers, and primary-key validators, every synchronous lock block across write.rs, read.rs, and truncate_async strictly obeys the total acquisition order:
pending_commit (Mutex) --> pending_writes (RwLock) --> indexing.memory_index (RwLock)
Asynchronous coarse-grained serialization sits outside these short critical sections:
maintenance_lock: shared read guard forwrite_with_durability_asyncandflush_async; exclusive write guard forcompact,truncate_async, andvacuum.write_with_durability_asyncexplicitly drops its_maintenance_guardimmediately after appending to WAL,memory_index, andpending_writes, preventing self-deadlock when autocommit orBSDB_CACHE_GBspillover triggerscommit_async().
3. Core Architectural Invariants & Mechanisms¶
3.1 Linearizable Reader Double-Check (buffer_epoch)¶
Without an atomic barrier spanning manifest loading and in-memory buffer inspection, a concurrent reader could read an older cached manifest (v{N-1}) just as a flush transitions PendingCommit from Staging to ManifestCommitted, missing the committed batch completely.
Table::read_expr_stream_async prevents torn snapshots through a lock-free sequence-checked retry loop (0..8usize):
Load
epoch1 = self.buffer_epoch.load(Ordering::SeqCst).Read the durable manifest snapshot (
manifest_manager.load_latest_full().awaiton attempt 0; escalating to uncachedmanifest_manager.load_latest_direct().awaitwhenforce_direct = true).Acquire the synchronous triple
(pending_commit, pending_writes, memory_index)in canonical order, extracting:committed_ver:pc.committed_manifest.versionifpc.phase != CommitPhase::Staging.staging:(pc.batches, pc.frozen_memory_index)ifpc.phase == CommitPhase::Staging.active: current(batches, memory_index)frompending_writes.
Drop the synchronous locks and load
epoch2 = self.buffer_epoch.load(Ordering::SeqCst).Check
stale_manifest = committed_ver.is_some_and(|cv| version < cv).If
epoch2 != epoch1 || stale_manifest, setforce_direct = trueand retry.
On transition from Staging to ManifestCommitted, the committer updates committed_manifest, clears cached table-level cluster_indexes and vector_routers (so ANN fast paths cannot serve pre-commit centroids/graphs over stale views), and calls bump_buffer_epoch(). Table-level cluster fast paths are likewise restricted to quiescent intervals where staging_snap.is_none() && active_snap.0.is_empty() (read.rs:723).
3.2 Segmented WAL Rotation, Watermark Pruning & Torn-EOF Tolerance¶
Instead of truncating the entire _wal/ directory on commit, each open table writes Arrow IPC streams into per-instance, monotonically named segments (log_{ts:020}_{pid:06}_{seq:08}.arrow) tracked by WalSegmentRegistry (active vs sealed segments with associated HashSet<Uuid> transaction IDs):
Per-Transaction Tagging: Every
write_with_durability_asyncassigns a freshtx_id: Uuidand embedsbenostream:tx_id/benostream:seqschema metadata onto each WAL batch (tag_batch_with_wal_tx). UnderWalDurability::Sync,sync_wal()prefersfdatasync(File::sync_data()) and falls back tofsync(File::sync_all()), returning an error rather than a false acknowledgement if both fail.Pre-Commit Segment Rotation: Before staging Parquet files,
flush_unlocked_asynccallswal.rotate_async(). The WAL worker flushes pending acks, finishes the ArrowStreamWriter, callssync_data(), moves the finished file intoreg.sealed, and opens a new active file for concurrent writers arriving while S3/Parquet staging is in progress.Watermark Pruning: After
ManifestCommittedrecordsappend_committed_wal_txin the manifest propertybenostream.committed_wal_tx(deduplicated and bounded to the trailingMAX_TRACKED_WAL_TX = 256entries incommit.rs#L560-L584),prune_committed_segmentsdeletes a sealed segment only if!entry.has_untagged && entry.txs.is_subset(&committed_set). Concurrent in-flight active files and any segment holding uncommitted transactions remain untouched on disk.Startup Idempotency & Torn-EOF Recovery: On reopen (
TableBuilder::build_async),open_wal_segmentandSafeIpcSegmentIter:Gracefully skip segments that vanished concurrently before opening (
ErrorKind::NotFound).Treat 0-byte segments, zeroed preallocation headers (
<= 8bytes), truncated Arrow IPC continuation prefixes (< 4bytes matching0xFFFFFFFF), 0-length schemas, andUnexpectedEofmid-schema asOpenedWalSegment::Empty.Treat trailing
UnexpectedEofinside an incomplete final batch frame as end-of-stream (warn!), preserving every prior synced complete batch in the segment.Reject genuine mid-stream corruption (invalid non-zero magic bytes, invalid/oversized
meta_len > 64 MiBor< 0, or non-EOF FlatBuffers corruption) with an error requiring operator intervention.Skip already-committed
tx_ids present inbenostream.committed_wal_txand register recovered segment TX sets viaregister_recovered_segmentso the next commit prunes them cleanly.
3.3 Idempotent Staging, V3 Strict OCC & Metadata/Catalog Convergence¶
WAL-TX Idempotency Across Ambiguous Manifest Acks: Inside
stage_and_commit_manifest_async, before staging Parquet files, the committer inspectsbenostream.committed_wal_txon the durable manifest head (load_latest_direct()). If allwal_tx_idsfor theStagingcommit are already recorded on the durable head (for instance, after an ambiguous S3 timeout where the commit actually succeeded), it updates the local version cache monotonically and converges without writing duplicate Parquet segments.V3 Row-Lineage Strict OCC: When
effective_fv >= 3,CommitMetadatasetsexpected_version: Some(head_ver). Because V3 Parquet segments embed immutable_row_idand_last_updated_sequence_numbercolumns assigned fromhead_ver, a stale base version makes those staged segments invalid. OnBenoStreamError::ManifestVersionMismatch, the committer callscleanup_staged_segment_files()to delete the unusable Parquet files and retries up toMAX_V3_RETRIES = 64with2–15 msrandomized backoff (write.rs:1698-1715).Monotonic Iceberg
TableMetadata&version-hint.textCAS:TableMetadata::commit_update_atomicdiscoverslatest_version_in_store, checks idempotency (MetadataUpdateOutcome::AlreadyCurrent), merges snapshots monotonically viamerge_snapshot_monotonic, writesmetadata/v{N}.metadata.jsonwithPutMode::Createacross up to 64 CAS attempts, and advancesmetadata/version-hint.textviaadvance_version_hintusing conditionalPutMode::Update(UpdateVersion { e_tag, version })so delayed writers can never regress the hint file.REST Catalog Conflict Retry:
commit_catalog_step_asyncsubmits optimistic Iceberg requirements (assert-table-uuid,assert-ref-snapshot-id) and retries HTTP 409 /CommitFailedExceptionresponses up toMAX_CATALOG_RETRIES = 8, reloading remote catalog state, checking ifsnapshot_idalready landed, re-mergingTableMetadataif the catalog location moved ahead, and sleeping5 * (attempt + 1)ms between retries.
3.4 Staged Distributed Connector Contract (stage.rs)¶
For distributed JVM connectors (Spark BatchWrite executors staging Parquet files while the driver commits metadata), src/core/table/stage.rs aligns with M2 invariants without forcing multi-GB executor payloads through the driver:
Table::write_mode()returns"files"for Iceberg Format Version 2 (allowing stateless parallel executor staging viastage_data_files_async+ driver deduplicatedcommit_staged_entries_async) and"buffered"for Format Version 3 (routing through the WAL +PendingCommitV3 row-lineage OCC coordinator so concurrent tasks cannot emit overlapping_row_idranges).commit_staged_entries_asyncacquiresmaintenance_lock.read()+commit_lock, callsload_latest_direct(), deduplicates already-committed paths on retry, and callsensure_table_metadata_async+publish_snapshot_async.
4. Honest Architectural Scope & Boundaries¶
Consistent with BenoStreamDB’s systems-first contract, operators must respect four explicit boundaries of PendingCommit:
Single-Table Coordinator Only:
PendingCommit,commit_lock, andmaintenance_lockcoordinate within a singleTablehandle against shared object-store CAS (_manifest/v{N}.jsonandmetadata/v{N}.metadata.json). Multi-table cross-statement transactions and distributed two-phase consensus protocols are out of scope.Remote Object Storage + Ephemeral Local WAL: When opening an
s3://,gs://, oraz://URI without settingBSDB_WAL_DIRorTableBuilder::with_wal_dir(), the WAL defaults to local temporary storage (/tmp/benostream_wal/...) and logs a startuptracing::warn!. Un-committed WAL records in temporary local storage do not survive container or VM loss prior tocommit_async().256-Transaction Manifest Window:
benostream.committed_wal_txretains the trailingMAX_TRACKED_WAL_TX = 256UUIDs in_manifest/v{N}.json. Segment rotation +prune_committed_segments()removes committed.arrowsegments immediately duringfinish_pending_commit_tail_async(), preventing accumulation; however, if an operator restores a snapshot older than 256 commits alongside unpruned legacy WAL files, manual WAL cleanup may be needed.Durability Modes:
WalDurabilityexposes onlySync(default,fdatasync/fsyncper batch before client ack) andAsync(buffered worker sync everyBSDB_WAL_SYNC_BATCH_SIZE = 10batches orBSDB_WAL_SYNC_INTERVAL_MS = 100ms). UnderAsync, an un-synced tail window can be lost on process termination.
5. Verification Matrix (M2 Pillars 1–9)¶
Every transition in PendingCommit and every crash/fault boundary is covered by deterministic fault-injection, subprocess signal kill tests, CAS contention harnesses, and linearizability checkers in tests/test_m2_commit_state_machine.rs:
Pillar |
Verification Target |
Certified Test(s) |
|---|---|---|
Pillar 1 |
Scripted transient store failures ( |
|
Pillar 2 |
Real OS subprocess |
|
Pillar 3 |
WAL segment rotation with continuous reuse of the same |
|
Pillar 4 |
Sealed vs active WAL watermark pruning barrier under deterministic barriers and concurrent writer/committer contention |
|
Pillar 5 |
In-process tail resumption from |
|
Pillar 6 |
Pre-manifest failure recovery restoring |
|
Pillar 7 |
Concurrent Iceberg V3 writers verifying strict OCC, |
|
Pillar 8 |
Non-regressing |
|
Pillar 9 |
Continuous full-scan and ANN vector readers asserting linearizability across |
|
End-to-end converged multi-writer linearizability (V2 union model, V3 strict OCC _row_id uniqueness, and transient ToggleFaultyStore errors) and torn-WAL IPC parsing are additionally verified by tests/test_multi_writer_differential.rs, fuzz/fuzz_targets/wal_segment.rs, and scripts/pre_release_soak.sh.