feat(flatdb): persist via SST ingestion (opt-in) - #12401
Conversation
|
Claude finished @AnkushinDaniil's task in 5m 24s —— View job Review — SST-ingestion persist path (PR #12401)
This is a well-structured, clearly-motivated change with strong benchmark evidence and thoughtful failure handling. It's correctly marked draft and the flag is default-off, so nothing here is production-live. My comments are what I'd want resolved (or explicitly acknowledged) before the flag is ever flipped on. Inline comments posted on specific lines. FindingsMedium
Low
Things that look correct
VerdictNot mergeable yet — which matches the author's own draft status. The blocking items are the two the PR already lists (multi-CF atomic ingest + startup orphan sweep); I'd add the live-reader torn-state dimension and the failure-path regression test to that list. I did not build/run the test suite in this review. |
|
@claude re-review |
|
Claude finished @AnkushinDaniil's task in 1m 45s —— View job Re-review — SST-ingestion persist path (PR #12401)
The five review commits since the last pass resolve every Low finding and explicitly acknowledge one of the two Mediums in the PR body. Nice, targeted fixes. Details below. Low findings — all resolved ✅
Medium findings1. Cross-column read atomicity during a persist — now acknowledged. ✅ 2. Failure-path rollback regression test — still open. The invariant is correctly implemented — in Add failure-path rollback test → VerdictStill not mergeable — matching the author's own draft status. All four Low findings are resolved and the live-torn-read atomicity dimension is now explicitly documented as a blocker. The remaining items are the author's listed blockers (multi-CF atomic ingest + startup orphan sweep + concurrent-reader test), plus I'd still add the failure-path rollback regression test to that list. Nothing here regresses existing behavior — the flag stays default-off. I did not build or run the test suite in this review. |
|
@claude re-review |
|
Claude finished @AnkushinDaniil's task in 1m 51s —— View job Re-review — SST-ingestion persist path (PR #12401)
One new commit since the last pass ( Prior Medium #2 — failure-path rollback regression test — now resolved ✅
This is the right way to test it: the hook injects the failure at the exact native-ingest boundary, so the assertion covers the real Note (not blocking): the test seam is a production-code field ( Status of the four earlier Low findings — all still resolved ✅No regressions from the earlier fix commits (fail-loud CF options, named L0-drain constants + warn, whole-slab pin for zero-length entries, trimmed entry-pool buckets + documented retention). Nothing in Remaining open blockers (unchanged, author's own list)
The live torn-read dimension is now correctly documented in the PR body, and this failure-path test closes item (4). VerdictStill not mergeable — matching the author's own draft status. The remaining gaps are items (1)–(3) above, all of which the author already lists as merge blockers. The failure-path rollback test I'd asked for is now in place and correct. The flag stays default-off and nothing here regresses existing behavior. I did not build or run the test suite in this review. |
|
@claude re-review |
|
Claude finished @AnkushinDaniil's task in 3m 51s —— View job Re-review — SST-ingestion persist path (PR #12401)
One new commit since the last pass ( Blocker (1) — crash-atomicity — resolved ✅Rather than the multi-CF
Blocker (2) — startup orphan sweep — resolved ✅
Blocker (3) + live torn-read window — resolved ✅A
Earlier findingsAll four earlier Low findings and the failure-path rollback test (prior Medium #2) remain resolved — nothing in Minor nits (non-blocking, Low)
VerdictAll previously-listed merge blockers are now addressed (crash-atomicity via redo marker + roll-forward, startup sweep, concurrent-reader consistency), and the live cross-column torn-read window is closed by the reader gate. No Critical/High/Medium items remain open. The flag stays default-off and nothing here regresses existing behavior; the only outstanding items are the Low nits above. I did not build or run the test suite in this review. |
|
Claude finished @AnkushinDaniil's task in 5m 16s —— View job Re-review — SST-ingestion persist path (PR #12401)
The redo-marker commit closes the three blockers from prior rounds — but it introduces one new High correctness gap on the in-process failure path that the redo-marker design otherwise handles correctly for the crash case. Prior blockers (1)–(3) — now addressed ✅
New findingHigh — failure-path rollback leaves a persisted torn base once any column has been ingested. (inline) This contradicts the PR body's stated guarantee ("the pointer is not advanced and recovery re-executes from the previous pointer"). Rollback is only safe before the first successful ingest; after that the failure path must become roll-forward-only (keep the marker + remaining files, rethrow, let startup finish). The existing Earlier Low findings — all still resolved ✅Fail-loud CF options, named L0-drain constants + timeout warn, whole-slab pin for zero-length entries, trimmed entry-pool buckets + documented retention — no regressions in Minor (non-blocking, unchanged from last round): VerdictNot mergeable — one unacknowledged High correctness gap on the failure-path rollback (flag stays default-off + draft, so nothing is production-live, but it undermines a core stated guarantee and should be fixed before the flag is flipped on). The three prior blockers are genuinely closed. I did not build or run the test suite in this review. |
|
@claude re-review |
|
Claude finished @AnkushinDaniil's task in 2m 47s —— View job Re-review — SST-ingestion persist path (PR #12401)
One new commit since the last pass ( Prior High — mid-loop ingest failure left an unrecoverable torn base — resolved ✅
Regression test is on-point. The Prior blockers (1)–(3) + Low findings — still resolved ✅Crash-atomicity redo marker, startup sweep + roll-forward, reader snapshot gate, and the four earlier Low fixes — no regressions in Still open (from @LukaszRozmej's review, posted after my last pass)Medium — backpressure runs outside the commit Low (non-blocking), also from @LukaszRozmej — unchanged since that review:
VerdictThe prior High is genuinely fixed — the failure path is now roll-forward-only once any column is live, with a targeted regression test. The remaining open item is @LukaszRozmej's Medium on backpressure being able to fail a committed persist, plus the Low nits above. Flag stays default-off and nothing here regresses existing behavior. I did not build or run the test suite in this review. |
At 10x mainnet state the flat-DB checkpoint commits a full compacted snapshot (~32 blocks) as one RocksDB WriteBatch, overflowing the memtable and triggering a flush -> L0 pile-up -> compaction burst that saturates I/O and stalls concurrent reads for 2-24s (measured: 34 slow persists, max 24.6s; RPC read max 21.9s). Add an opt-in path (--FlatDb.PersistViaSstIngestion, default off) that builds one sorted SST per column off-heap (via a collecting write batch that reuses the existing encoders) and ingests it as a metadata-only add, bypassing the memtable entirely. Benchmarked at 10x under a progressing chain (window=8, 50 users, 4m): slow persists 34 -> 2 (max 24.6s -> 4.0s), read p99 1100ms -> 14ms, p95 23ms -> 6ms. State roots validated (blocks processed, 0 errors). Per-CF ingest is not yet crash-atomic with the currentState pointer; a crash mid-persist leaves the pointer behind the ingested data and recovery re-executes/overwrites it. Follow-up: multi-CF rocksdb_ingest_external_files.
…sure) Hardens the SST-ingestion persist path found to OOM the node at 10x state: - byte-capped buffer (128 MiB): flush+ingest an SST and free the buffer at the cap, so a large persist never holds the whole batch in managed memory. - L0 backpressure: after ingesting, throttle the persist thread until L0 files drain, replicating the memtable write-stall flow control that raw ingestion bypasses (unbounded ingests otherwise pile up the native compaction set). NOTE: these reduce but do NOT eliminate the 10x OOM. Benchmark forensics show NM still hits the ~62 GB box ceiling (baseline 48 GB + ingestion's ~14 GB native RocksDB growth), OOM-killing + restarting under the progressing-chain load. Root of the residual native growth not yet pinned (needs a RocksDB memory-by-type profile). Ingestion stays default-off; not viable at 10x on a 62 GB host until the native memory is bounded.
…eanup, config) Addresses PR review on the SST-ingestion path: - RocksDbPersistence.CreateIngestWriteBatch: dispose every column batch and the RocksDB snapshot in try/finally so nothing leaks if an ingest throws, and surface the first failure so the currentState pointer is not advanced past a partial persist (recovery re-executes from the previous pointer). - ColumnDb.SstIngestWriteBatch: delete the staged .sst on Finish/ingest failure so sst_ingest/ does not accumulate orphaned files. - Make the read-path trie-node RLP cache capacity configurable (--FlatDb.TrieNodeRlpCacheCapacity, default 262144); correct the inaccurate "off-heap"/"opt-in" wording (the buffer is a byte-capped managed-heap buffer). - Add SstIngestionTests: RocksDB-backed coverage of ingest round-trip, SelfDestruct+recreate last-write-wins dedup, delete tombstones, currentState pointer advance, and MemDb fallback.
SstFileWriter used default ColumnFamilyOptions, so ingested L0 files carried no filter policy and RocksDB defaults for block size and compression, while the column family is configured with ribbon filters. Point reads during the L0 window had to probe data blocks in every overlapping ingested file. Retain the per-CF options built at open and pass the column's own options to the writer, so ingested files match the CF's configured table format. Neutral in the 10x A/B (tail unchanged - the L0 files are page-cache-hot in that regime); makes the on-disk format consistent with the CF configuration for cold-cache regimes. Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
The ingest batch buffered each 128 MB chunk in a Dictionary<byte[], byte[]?> plus a full List copy for sorting: 2-3M small key/value arrays that survive to gen2 before dying at flush, and multi-MB bucket/entry/copy arrays churning the LOH — ~1.5-2.5 GB of promoted + LOH garbage per persist across the six column families. A GC trace ties the resulting blocking NonConcurrent gen2 collections (OutOfSpaceLOH / AllocSmall, 1.2-2.1 s STW) to the RPC read tail. Buffer key/value bytes in pooled 1 MB slabs indexed by unmanaged entry structs in a pooled array instead. Deduplication moves to a flush-time stable sort (keep the last write per key) — semantics-preserving because duplicate keys across chunk files are already resolved by ingest order via AllowGlobalSeqno; the SST is written through the native pointer bindings, so the flush allocates nothing per entry. Steady-state persists produce ~zero unpooled garbage. New test covers overwrite, delete and intra-chunk dedup across a real chunk boundary. 10x benchmark: block-proc and memory unchanged, RPC failures 0. Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
…oundary don't throw Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
…document pool footprint Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
…ter and staging dir untouched Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
… gate, and startup sweep
- defer all per-column ingestion to the commit point: chunks are staged as
SST files on disk (memory stays bounded) and each column ingests its whole
file list in a single IngestExternalFiles call
- WAL-synced redo marker {target state, staged file names} written before the
first ingest and cleared atomically with the currentState advance; on reopen
a pending marker is rolled forward (re-ingest remaining staged files, advance
the pointer), leftover staging files without a marker are swept and logged
- ReaderWriterLockSlim gates new reader snapshots out of the ingest commit
window (per-CF ingests + pointer write), closing the live torn-read window;
the L0 headroom throttle runs outside the gate
- on a live ingest failure the marker is cleared before staged files are
deleted, so a marker can never outlive its files
- tests: roll-forward on reopen (crash between column ingests and after all
ingests), startup orphan sweep, concurrent cross-column reader consistency
Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
… live Rollback (clear marker, delete staged files) cannot un-ingest columns that are already live and destroys the material startup recovery needs, leaving a torn base durably. Keep the marker and remaining staged files when at least one column was ingested so a retried persist or startup recovery rolls the commit forward; rollback stays for failures before the first ingest. Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
WaitForIngestCompactionHeadroom runs after the commit is already durable (pointer advanced, marker cleared). A throw there (e.g. a column disposed during shutdown) propagated out of CommitIngest and surfaced a committed persist as a failure, so a retry would hit the currentState != from guard. Backpressure is flow control, not part of the commit: swallow and log. Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
…from one array Move RecoverInterruptedIngest out of the field initializer into the ctor gated on PersistViaSstIngestion, and derive the ingest set, L0 throttle and batch array from the single IngestColumns[] source. Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
…parer, pool staged-file list Log File.Delete failures in FlushChunk cleanup + DeleteStagedFiles instead of empty catch; cache EntryComparer per batch; back _stagedFiles with ArrayPoolList; document the unsafe sst-writer slab-bounds invariant; drop the s_ static prefix. Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
CommitIngest held the ingest write-lock across the WAL fsync (_db.Flush(onlyWal: true)), blocking every CreateReader / snapshot for the whole persist window. The committed pointer/marker WriteBatch is already visible to new snapshots, so only its durability depends on the fsync; move the fsync after ExitWriteLock. The gate now covers just the six column ingests plus the atomic pointer/marker batch, which is what the concurrent-reader torn-state test requires. A crash before the fsync still leaves the redo marker on disk and reopen rolls the commit forward to the same target, so crash-safety is unchanged. Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
The RecoverInterruptedIngest orphan sweep deleted staged files with an unguarded File.Delete, so a locked or permission-denied orphan would crash startup recovery. Wrap each delete in a log-and-continue try/catch, matching DeleteStagedFiles and the FlushChunk cleanup; a file that cannot be removed is retried on the next startup sweep. The summary log now reports the count actually deleted. Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
Address three review findings on the SST-ingest persist path: - A partial multi-column ingest (a later column throws after an earlier one is already live) now completes the commit inline, still holding the write lock, by rolling the durable marker forward - so no snapshot ever observes the torn base. Only a persistent ingest failure falls through to the marker being kept for the next persist or startup to finish. - Startup ingest recovery now runs whenever the store supports ingestion, regardless of the PersistViaSstIngestion flag, so a flag-off restart after a flag-on crash still rolls the marker forward instead of leaving a torn base. - The post-commit L0 backpressure is bounded by a single 30s budget across all columns and honours a cancellation token, so a graceful shutdown is no longer held for up to ~180s. Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
The per-persist slab-reference list is now an ArrayPoolList backed by a dedicated pool instead of a plain List<byte[]>, keeping the SST ingest batch's steady-state allocations off the managed heap and off the shared array pool. Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX
e54d5f4 to
d0a5fab
Compare
Startup recovery intentionally runs regardless of PersistViaSstIngestion, so a restart with the flag back off (the default) still rolls a pending ingest marker forward instead of leaving a torn flat base. Only the flag-on reopen was covered; parameterize the crash roll-forward test so re-gating recovery on the flag would fail the new case.
|
@claude The flag-off reopen regression test is now on head |
|
Claude finished @AnkushinDaniil's task in 4m 43s —— View job Re-review — SST-ingestion persist path (PR #12401) @
|
Issue
At x10 (~2.2 TB) the flat-DB checkpoint commits a full compacted snapshot (~32 blocks) as one RocksDB WriteBatch per column. That overflows the memtable, forces a flush, and the L0 pileup + compaction burst stalls concurrent reads for tens of seconds.
Fix
Persist via SST ingestion instead of the memtable path: build the SST files off the reader gate and ingest them (crash-atomic with a redo marker + startup sweep). Opt-in via
--FlatDb.PersistViaSstIngestion(default off).Benchmark — x10, confound-free 3× median (flag off vs on)
The stall is real on master (44 s, 54 slow tasks/run); SST cuts it to 3 s (−93%), every round, at RSS −6% and read latency flat-to-better. x1 mainnet (single run): RSS 45.7 → 40.4 GiB, getProof flat.