feat: query-time MVCC DELETE masks for the duckdb-native pinned cache (#819 PR3)#1142
Conversation
5410dc0 to
c258f05
Compare
3d0ab3a to
96dcf7f
Compare
wmalpica
left a comment
There was a problem hiding this comment.
the scan documentation needs updating for the new behavior.
docs/super-sirius/scan.md:38 says resident cached batches are forwarded as-is, and line 40 says resident working-set size is just the input size. Line 221 similarly says identity-layout cached batches flow through untouched.
MVCC-pinned DuckDB chunks can now carry a keep-mask, allocate a BOOL8 expansion and filtered table, synchronize the stream, and use a larger memory estimate. The documentation should
also describe:
- Per-query DELETE visibility masks and their preparation phase.
- Snapshot, insert, and column-mismatch fallback guards.
- Pin-time update-chain rejection.
- The pinned/pageable host-mask storage behavior.
- The additional GPU allocation and synchronization for masked chunks.
8e3043e to
bd06f19
Compare
…PR3)
Behavior-neutral foundation for query-time MVCC DELETE masks on the
duckdb-native pinned cache — the carrier and the GPU apply seam land
first, with no mask producer yet:
- mvcc_chunk_mask: bit-packed per-chunk keep-mask (cuDF bitmask
convention, 1 bit/row) as a storage-agnostic span + type-erased
retention, so the mask job can later back it with pinned staging
blocks while unit tests retain a plain vector.
- apply_host_keep_mask / apply_host_keep_bitmask: shared host-mask ->
device apply helpers. The byte variant is the factored tail of
positional_delete_filter::apply (which now delegates to it); the
bitmask variant uploads packed words and expands them with
cudf::mask_to_bools before apply_boolean_mask — no custom kernels.
- databatch_provider::get_next_batch() returns {data, mvcc_keep_mask};
drain_cached_provider moves the mask onto the split's new
scan_operator_input::mvcc_keep_mask member.
- gpu_ingestible::materialize_table's resident branch applies the mask
(one upload + mask_to_bools + apply_boolean_mask per chunk) and
awaits the stream before dropping the read-only batch — the pinned
mask upload is a true-async DMA and a HOST-tier chunk's staged
device copy lives on the read-only handle (the decoder's
submit_and_await discipline).
- make_provider_for_pinned_entry hoisted to a declared factory (the
validate_pinned_entry_for_serving precedent) so the provider's
chunk<->mask-slot pairing is unit-testable.
Tests: [host_keep_mask] (byte/bitmask agreement across word-boundary
shapes, mis-size refusal) and new [cached_serving] cases (drain
forwards mask identity; provider pairs slot i with chunk i).
Part of sirius-db#819 (PR3, commit 1/5).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CPU-side correctness core for query-time DELETE masks: - capture_mvcc_visibility_plan: one serial pass over the row-group tree validates the pinned prefix (contiguity, chunk boundaries on row-group starts, last-row-group clamp to pin coverage, 32-row slice alignment for the lock-free bit fill, start_time >= v_base re-pin re-assert) and records a per-row-group dirty probe via the public RowGroup::GetPartitionStats (count_type APPROXIMATE <=> version state) — enabling the whole skip hierarchy: a version-state-free table costs one non-loading scan and the job does nothing. - fill_keep_mask_for_row_groups: worker-safe bit-packed fill in cuDF's bitmask convention via cudf/utilities/bit.hpp helpers; clean slices bulk-set ones without touching MVCC state; dirty slices walk 2048-row vectors through RowGroup::GetSelVector (both transaction ids, so own-transaction deletes are honored); plain stores — task ranges are word- (and cache-line-) disjoint by the 32-row alignment invariant. - any_update_chains / has_any_version_state: shared probes for the plan-time guards (updates are invisible to GetSelVector; version state marks divergence from the disk image). metadata_parse_chunk() moves from the ingestible's anonymous namespace into duckdb_native_metadata so the mask job slices row-group work into the same task unit as the metadata walk. Known conservatism, documented: a RowVersionManager created by the session's own writes stays attached after append cleanup, so same-session-written row groups probe dirty forever (the per-vector GetSelVector fast path keeps that cheap); freshly-loaded tables — the ATTACH-then-pin serving shape — probe clean. Tests: [duckdb_mvcc_visibility] — fill bits checked against DuckDB's own `SELECT rowid` visibility across vector/row-group/word boundaries, own-txn deletes + rollback, pin-coverage clamp under appends, persisted tombstones through a reopened database, capture validation throws, update-chain and version-state probes. Part of sirius-db#819 (PR3, commit 2/5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ius-db#819 PR3) Wires the mask producer: on a duckdb+mvcc cache hit, try_assign_cached_entries records a mask-job request (a COPY of the entry's mvcc metadata plus the query-side DataTable*/ClientContext* and per-chunk memory spaces — no pinned_entry references escape) and hands the same mask set to the cached provider; prepare_for_query runs all pending jobs BEFORE start_metadata_processing(), so masks are finished plain buffers when the serving sequencer walks its slots (it is single and serial — a wait there would be head-of-line blocking across every scan op; the dispatcher is fresh and idle at this point). run_mvcc_mask_jobs: serial capture per request (zero version state anywhere -> return with every slot null: no reservation, no tasks); pinned mask storage acquired reservation-first exactly like the decoder's staging path — dirty chunks grouped by host NUMA node, ONE consolidated request_reservation + allocate_multiple_blocks per node, per-chunk word spans carved 64-byte-aligned within block boundaries, the {reservation, blocks} bundle retained by the published masks; fan out one task per <= metadata_parse_chunk() row groups (a task never spans chunks — the lock-free bit-write invariant); publish only chunks that actually dropped rows. fan_out_and_join (generic — the PR4 delta job reuses it): slots are acquired before each enqueue and moved into the task, so a stopping dispatcher's silent enqueue drop still releases the slot — the join fires and the completed-count check turns the drop into a loud error instead of a deadlock; the first task exception is rethrown after the join. Cache-or-CPU backstop: for mvcc entries, any failure past the identity gate in try_assign_cached_entries (column gather, serving validation, metadata/chunk mismatch) now rethrows loudly instead of falling back to the disk-native read — that path is MVCC-blind and increasingly stale under the pin's checkpoint suppression. Parquet entries keep today's log + disk fallback. Tests: [mvcc_mask_job] — concurrent disjoint assembly, first-error rethrow, dropped-task loudness (no deadlock), zero tasks, end-to-end publish against DuckDB visibility with pinned-memory verification (cudaPointerGetAttributes), clean-table no-op, two requests over one table. Part of sirius-db#819 (PR3, commit 3/5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # CMakeLists.txt # src/include/scan_manager/sirius_scan_manager.hpp # src/scan_manager/sirius_scan_manager.cpp
PR3) While a duckdb table is MVCC-pinned, a GPU plan touching it either serves exactly from the pinned cache (DELETE keep-masks) or is refused in the create_plan(LogicalGet) probe — the only point where a refusal still becomes a clean transparent CPU fallback (the sirius-db#1113 seam; later refusals have none). The disk-native path is MVCC-blind and the pin's checkpoint suppression makes its snapshot increasingly stale, so scans the pin cannot serve reach it only when the table provably matches its last-checkpointed image. Guards, cheap to expensive: (a) snapshot-too-old: the transaction opened before the pin (start_time < v_base on the table's own catalog). (b) insert-present: committed rows beyond n_cache, or the transaction's own local-storage appends — not servable until the PR4 insert-delta reader. (c) update-present: in-place UpdateSegment chains on a scanned column — invisible to GetSelVector, so cached values would be stale. Also refused at pin time (build_duckdb_pin_info), mirroring the loud transient-rows failure; CHECKPOINT folds chains away. (d) column-mismatch with the clean-table relaxation: when the pin cannot serve the scan's columns, allow the disk-native path only if the table has zero version state and no update chains — sound because DuckDB cannot clean up version info an older active transaction still needs, and later commits are invisible to this query's start_time. Matching is single-source-of-truth: can_serve_with_columns now delegates to cache_entry_info::matches_duckdb_table + column_projection_for (shared with the probe via the new find_pinned_entry_for_duckdb_table accessor), and the projection pre-filters rowid/virtual/empty/field-identifier columns instead of relying on sentinel lookup misses (GetPrimaryIndex throws on field identifiers). Prepared statements re-run the probe per execute (OnExecutePrepared forces a rebind for PhysicalSiriusExecution), so the guards are always evaluated under the executing transaction. Part of sirius-db#819 (PR3, commit 4/5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… block (sirius-db#819 PR3) Staging blocks are not virtually contiguous, so a chunk mask cannot span two — and a narrow column under a big batch budget genuinely produces such chunks (a 100 MB BIGINT chunk is ~12.4M rows = a 1.5 MB bitmask vs the integration config's 1 MB blocks; the earlier loud throw fired at prepare, where no CPU fallback exists, and invalidated the database). Oversized masks now keep a plain pageable buffer for that chunk instead — the storage-agnostic carrier makes this a local choice; correctness is identical (cudaMemcpyAsync stages pageable memory out before returning) and only the true-async-DMA benefit is lost, with a log line recording it. Part of sirius-db#819 (PR3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…PR3)
duckdb exceptions format with duckdb_fmt's printf ("%s"/"%llu"), not
fmt-style "{}" — the new MVCC guard messages (and the pre-existing
overflow-string probe message from sirius-db#1113, same block) printed literal
"{}" placeholders. These messages are the only clue in the transparent
fallback log line, so make them render.
Part of sirius-db#819 (PR3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ius-db#819 PR3) End-to-end [pin_table_mvcc_delete] over the transparent pipeline, with transparent-execution counters asserting the exact path per query ({1,0,1} = cache-served on GPU; {0,1,0} = plan-time decline to CPU): masked serving — committed post-pin deletes across word/vector/ row-group boundaries; pre-pin checkpointed tombstones (silently served before this PR); own-transaction uncommitted deletes + rollback; the all-visible fast path; host tier; multi-chunk pins (incl. the oversized-mask pageable path); a fully-deleted table (and the bare count(*) rowid-only scan, which the cache never serves — pre-sirius-db#819 it read the stale disk image and would count 50000 here — now correctly declining once diverged); masks recomputed per query; a masked pin joined to an unpinned table. guards — snapshot-too-old across two connections (older transaction falls back with its pre-pin-correct rows, serves from the cache after committing); committed and own-transaction inserts; in-place updates (incl. SET NULL); column-mismatch on a provably-clean re-attached table (disk path allowed) vs after a delete (declined); pin-time update-chain refusal lifted by CHECKPOINT. Part of sirius-db#819 (PR3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comments describe the code's constraints (lock-free alignment invariant, pinned-DMA lifetime, probe conservativeness, reservation-first storage), not the ladder position that introduced them; issue/PR numbers belong in the tracker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mates An unmasked resident chunk serves a zero-copy view, so its working set is just the batch bytes — but a chunk with an MVCC keep-mask is filtered by copy at materialize: the input batch and the compacted output (up to input-sized) coexist at peak, plus the BOOL8 expansion column (1 B/row) and the uploaded bitmask words. Report that peak (2x batch + row_count + mask words) so task admission budgets masked chunks honestly instead of undercounting by roughly the batch size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…PR3) test_pin_table_duckdb_native_cache.cpp detected pinned-cache hits by mutating the base table post-pin (INSERT) and requiring the query to keep returning the pin-time snapshot. That detection mechanism encodes the pre-sirius-db#819 contract that a pinned table may serve stale data. Under the PR3 guards a post-pin insert makes the pin unservable: the plan-time refusal transparently falls back to DuckDB (not gated by enable_duckdb_fallback, which only gates GPU execution-error replays), so the query returns live rows and the stale-snapshot assertion can never hold again. The correctness-relevant half of its regression surface — a scan silently falling through to storage instead of serving the pinned cache — is covered by test_pin_table_mvcc_delete.cpp: with post-pin deletes, a fall-through returns tombstoned rows and fails the value assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sts' oracles (sirius-db#819 PR3) The plan-printer setup unset SIRIUS_DISABLE and never restored it. The harness relies on that variable staying set between tagged tests: the statically linked extension auto-loads into every duckdb::DuckDB the binary creates, and SIRIUS_DISABLE is what stops it auto-initializing a SiriusContext in untagged tests' instances. With the guard leaked away, every later bare instance came up with transparent GPU execution on, so the mvcc visibility tests' SQL oracle (SELECT rowid) was served by the MVCC-blind native GPU scan (sirius-db#1143) and returned deleted rows as visible — failing all five oracle-based tests at suite position ~1508, deterministically, locally and in CI. Hold the unset in a scoped guard that restores the prior value, and pin the visibility tests' connections to gpu_execution=false so their oracles stay on the CPU path regardless of other files' env hygiene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sirius-db#1143) The disk-native GPU read applies no visibility filtering, and until now only MVCC-pinned tables were guarded at plan time — an unpinned table with uncheckpointed deletes, in-flight inserts, or update chains sailed into the blind read and returned wrong rows. Mirror the pinned branch's cache-or-CPU discipline for everything else: refuse transaction-local appends (LocalStorage rows are invisible to the native read), update chains on scanned columns (stale values), and any row not visible at this snapshot — each thrown where create_plan's gate still turns it into a clean CPU fallback. The visibility check is a new all_rows_visible helper: the non-loading COUNT_APPROXIMATE probe skips clean row groups outright, and probe-dirty ones are answered by DuckDB's own RowGroup::GetVisibleRowCount, one call per row group. The precision matters — session-written tables stay probe-dirty forever (their RowVersionManager never detaches), and guarding on the probe alone would push every CTAS'd table off the GPU permanently. Residual plan->prepare race (a commit landing between the guard and the scan's metadata capture) is documented in the guard; the keep-mask machinery planned for sirius-db#1143 closes it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…us-db#1143) The unpinned-scan guard walked the segment tree twice (update chains, then visibility) and the pinned column-mismatch relaxation (guard d) walked it twice more with the conservative probe. Fold everything into one check_native_read_mvcc_state walk, tiered cheap-to-expensive per row group: update-chain pointer checks on the scanned columns, the non-loading version-state probe, and — for probe-dirty row groups only — DuckDB's own RowGroup::GetVisibleRowCount against the physical count. Guard (d) adopting the fused check is also a precision upgrade: its fall-through target IS the disk-native read, so it now takes the same exactness path as an unpinned scan. A session-written pinned table with an unservable column (rowid scans, unpinned columns) stays on the native GPU path while every row is visible, instead of declining to CPU on the forever-dirty probe; the column-mismatch test drops its DETACH/ATTACH workaround and asserts exactly that. all_rows_visible and has_any_version_state are subsumed and deleted (the file-local probe and any_update_chains — guard (c) and pin-time refusal — remain). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1160 The update-chain / clean-table / unpinned-scan guards walked every row group of the scanned table inside create_plan(LogicalGet) — serial, on the query thread, per query. Disable them (#if 0, kept as reference for the execution-time rework tracked in sirius-db#1160) rather than pay that planning cost. Still live: the O(1) guards sirius-db#819's fallback constraint requires at plan time — snapshot-too-old, insert-present, and transaction-local appends on MVCC-pinned tables — plus a now-unconditional column-mismatch decline (strict cache-or-CPU; the clean-table disk relaxation was the part that needed the walk). While deferred: post-pin UPDATE chains on a pinned table serve stale cached values (UPDATE is out of scope for sirius-db#819; pin_table still refuses tables that already carry update chains), and unpinned duckdb-native scans are MVCC-blind again (sirius-db#1143 re-exposed). The corresponding integration cases are disabled with the same pointer; the column- mismatch case is retargeted to the unconditional decline.
The disabled-guard comments narrated the deferral ("deferred to",
"stays off", "MVCC-blind again") instead of describing the code as it
stands. Rewrite them state-descriptive with future-work pointers only
(sirius-db#1160 execution-time walk, sirius-db#1143 persistent-storage insert/delete,
sirius-db#1162 UPDATE), and retarget the keep-mask pointer from the sirius-db#819 PR6(d)
rung label to sirius-db#1143, its home after the issue reorg.
The #if 0'd integration cases (post-pin in-place updates; the three unpinned-scan guard cases) come back with the execution-time walk (sirius-db#1160; see also sirius-db#1143, sirius-db#1162) instead of sitting disabled in-tree.
run_mvcc_mask_jobs was one monolith hiding capture, carve, fill, and
publish; split it into prepare_mvcc_mask_tasks (serial plan captures,
NUMA-grouped reservation-first carve, fill-task construction) →
fan_out_and_join → finalize_mvcc_masks, exposed separately so tests
(and gdb) can drive each stage without a dispatcher.
mvcc_chunk_mask drops the type-erased retention field: words is now an
aliasing shared_ptr that owns the pinned {reservation, blocks} bundle
(or the pageable fallback vector) directly, masks pass by value (a
copy is a refcount bump), and a default slot stays the all-visible
fast path. Prepare installs each dirty chunk's carved mask straight
into its request's set, the fill tasks write through the slot, and
finalize resets carved-but-clean slots — the mvcc_mask_work staging
duplicates (words/retention/row_count) disappear.
Mask requests dedup per pinned entry (a self-join shares one job),
provider handoff is sequenced behind the mask run as an ownership
handoff instead of sharing, and splits carry their keep-mask by value,
self-contained for the split's lifetime.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
# src/include/scan_manager/sirius_scan_manager.hpp
# src/scan_manager/sirius_scan_manager.cpp
Resident (pinned-cache) splits estimated a 1x working set even though serving them filters by copy: the mvcc keep-mask gather and the ingestible's row filter each hold input + compacted output (+ BOOL8 predicate) at peak, and the reservation path never consulted the split's working-set estimate for resident inputs — the masked 2x model was dead at its only consumer. The split now models a pending row filter too (row_filter_pending -> 2x batch bytes; a mask's envelope already covers the combined case since the phases run sequentially), stamped by drain_cached_provider from the new gpu_ingestible::has_row_filter(); the pipeline task computes the working set for resident scan inputs as well; and the no-history scan estimate returns max(bytes, working set) for resident chunks instead of plain bytes. The model seeds the cold start only: with history present, resident scans trust the learned peak un-clamped (a warm undershoot is repaired by record_on_failure + retry), while non-resident scans keep their pre-existing per-split decode floor — that working set is bytes the decode allocates unconditionally, not a heuristic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bd06f19 to
138cc01
Compare
Third rung of #819 (after #1113, #1115, #1136): a duckdb-pinned table keeps serving GPU queries while DELETEs land. Every query returns exactly what DuckDB returns at its own snapshot — pre-pin tombstones, post-pin committed deletes, and the query's own uncommitted deletes are all filtered out. States the cache can't represent decline to the transparent CPU fallback at plan time.
How it works
On a duckdb+mvcc cache hit,
prepare_for_querybuilds a per-query keep-mask over the cached rows before serving starts: one mask job per pinned table, fanned out across the scan-manager dispatcher and joined before serving begins. Serving itself is unchanged — masks ride the existing scan input, andmaterialize_tableapplies each mask on the GPU (mask_to_bools+apply_boolean_mask). Clean tables cost almost nothing: a non-loading row-group probe skips the mask entirely when a chunk has no version state.Cache-or-CPU guards (plan time)
While a table is MVCC-pinned, a GPU plan either serves from the cache or declines in
create_plan(LogicalGet)— the only place a refusal still becomes a clean CPU fallback. The plan-time checks are all O(1):count(*)counts too). This isn't about a column missing from the query; the cache simply doesn't hold that column's data, so it can't serve the scan — even when the table is otherwise clean.If a matched entry can't serve, prepare raises a loud error rather than silently reading the stale disk image.
Tests
[duckdb_mvcc_visibility],[mvcc_mask_job],[host_keep_mask]— the visibility walk, concurrent mask assembly, and the GPU apply helpers.[cached_serving]— mask forwarding through the serving path.[pin_table_mvcc_delete]— end-to-end masked serving and every live guard's fallback, asserting the exact path taken (cache-served vs CPU fallback).[can_serve],[pin_table_mvcc], and the existing pin suites are unchanged.Depends on #1113, #1115, #1136. Follow-ups: #1160 (walk-guard replacement), PR4 (INSERT delta).