Five fixes from PR #68 review (Cursor Bugbot + Codex + Cubic): * **scan_with_pending gains merge-shadow semantics** (Codex P1, Cubic P1#1): new `key_column: Option<&str>` parameter. When set, committed rows whose key value appears in any pending batch are excluded from the scan — making `scan_with_pending` correctly merge-semantic for chained updates instead of naively unioning. execute_update calls with Some("id"). Without this, a chained `update where age > 30` could match a row whose pending value already moved out of range. * **Multi-delete on same table no longer trips ExpectedVersionMismatch** (Cursor Bugbot HIGH): open_table_for_mutation routes through reopen_for_mutation when staging.inline_committed has the table, using the post-inline-commit Lance version captured at record_inline time. The legacy open_for_mutation_on_branch fence (Lance HEAD == manifest pinned) is correct cross-writer but wrong intra-query when deletes have already advanced HEAD on this table. Branch goes away when Lance ships two-phase delete (lance-format/lance#6658). * **Cardinality validation consolidated** (Cursor LOW + Codex P2 + Cubic P1#2 + Cubic P2): new exec/staging::count_src_per_edge + enforce_cardinality_bounds shared by mutation and loader paths. Restores the missing min-cardinality check on the engine path. Loader Merge mode passes Some("id") to dedupe edges being updated by id (not double-count committed + pending). Loader Append mode and engine path pass None (ULID-generated ids never collide). * **Dead count_rows_with_pending removed** (Cursor LOW): never called. * **Misleading concat-helper comment fixed** (Cubic P3): claimed schema normalization the helper doesn't implement. Updated to match reality. * **Documentation honesty** (Cubic P1#3): MR-794 narrows but doesn't eliminate the "Lance HEAD ahead of __manifest" drift class. Drift is unreachable for op-execution failures (the partial_failure test pins this), but a residual remains at the finalize→publisher boundary because Lance has no multi-dataset commit primitive: per-table commit_staged calls run sequentially before manifest commit. Updated docs/runs.md, docs/invariants.md §VI.25, docs/releases/v0.4.1.md to scope the claim precisely. * **Failpoint test pinning the residual**: new mutation.post_finalize_pre_publisher failpoint + two tests in tests/failpoints.rs that confirm the documented residual behavior. Catches future regressions that widen the residual. Test additions on tests/runs.rs: * chained_updates_with_overlapping_predicate_respects_intermediate_value * multi_statement_delete_on_same_node_table * cascade_delete_node_then_explicit_delete_edge_on_same_table * mutation_insert_edge_enforces_min_cardinality * load_merge_mode_dedupes_edge_for_cardinality_count 113/113 engine integration tests pass (runs + end_to_end + consistency + staged_writes + validators). Failpoints feature build runs in CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.2 KiB
Runs — REMOVED (MR-771)
The Run state machine and __run__<id> staging branches were removed in
MR-771. mutate_as and load now write directly to the target table
and call ManifestBatchPublisher::publish once at the end with
expected_table_versions (the per-table manifest versions captured before
the first write). Cross-table OCC is enforced inside the publisher; the
publisher's row-level CAS on __manifest is the single fence.
What this means in practice
- No
RunRecord, no_graph_runs.lance, no_graph_run_actors.lance. - No
omnigraph run *CLI subcommands and no/runs/*HTTP endpoints. - No
__run__<id>staging branches. (Legacy on-disk artifacts from pre-MR-771 repos are inert; MR-770 sweeps them in production.) - Cancelled mutation futures leave no graph-level state — only orphaned
Lance fragments, which the existing
omnigraph cleanuppipe reclaims.
Read-your-writes within a multi-statement mutation
A .gq query with multiple ops (e.g. insert Person … insert Knows …)
must observe earlier ops' writes when validating later ops (referential
integrity, edge cardinality). After MR-794 step 2+ this is implemented
via an in-memory MutationStaging accumulator in
crates/omnigraph/src/exec/staging.rs,
shared by both mutate_as and the bulk loader:
- On the first touch of each table, the pre-write manifest version is
captured into
expected_versions[table_key](the publisher's CAS fence at end-of-query). - Each insert/update op pushes a
RecordBatchinto the per-table pending accumulator. Lance HEAD does not advance during op execution. - Read sites (validation, predicate matching for
update) consumeTableStore::scan_with_pending, which scans committed via Lance and applies the same SQL filter to the pending batches via DataFusionMemTable. Same-query writes are visible to subsequent reads. - At end-of-query,
MutationStaging::finalizeissues exactly onestage_*+commit_stagedper touched table (concatenating accumulated batches; merge-mode dedupes byid, last-write-wins), and the publisher publishes the manifest atomically across all touched sub-tables. Cross-table conflicts surface asManifestConflictDetails::ExpectedVersionMismatch. - Deletes still inline-commit. Lance's
Dataset::deleteis not exposed as a two-phase op in 4.0.0; deletes go throughdelete_whereimmediately and record their post-write state inMutationStaging.inline_committed. The parse-time D₂ rule (below) prevents inserts/updates from coexisting with deletes in one query, so the inline path is safe for delete-only mutations.
This upholds docs/invariants.md §VI.23 (atomicity per query) and §VI.25 (read-your-writes within a multi-statement mutation, upheld).
D₂ — parse-time mixed-mode rejection
A single mutation query is either insert/update-only or delete-only. Mixed → rejected at parse time with a clear error directing the user to split the query. Reason: mixing creates ordering hazards (insert→delete on the same row would silently no-op because the staged insert isn't visible to delete; cascading deletes of just-inserted edges break referential integrity). Until Lance exposes a two-phase delete API, the parse-time rejection keeps both paths atomic and correct. Tracked: MR-793, plus a Lance-upstream ticket.
LoadMode::Overwrite residual
The bulk loader's Append and Merge modes use the staged-write path
described above. LoadMode::Overwrite keeps the legacy inline-commit
path: truncate-then-append doesn't fit the staged shape cleanly in
Lance 4.0.0, and overwrite has no in-flight read-your-writes
requirement (the prior data is being wiped). A mid-overwrite failure
can leave Lance HEAD on a partially-truncated table; the next overwrite
will replace it. Operator-driven (rare in agent workloads); document
permanently until Lance exposes Operation::Overwrite { fragments } as
a two-phase op.
Finalize → publisher residual
The staged-write rewire eliminates one drift class by construction at
the writer layer: an op that fails before pushing to the in-memory
accumulator (validation errors, missing endpoints, parse-time D₂
rejection) leaves Lance HEAD untouched on every staged table. This is
the case the partial_failure_leaves_target_queryable_and_unblocks_next_mutation
test pins.
A second, narrower drift class remains. MutationStaging::finalize
runs stage_* + commit_staged per touched table sequentially, then
the publisher commits the manifest. Lance has no multi-dataset atomic
commit, so the per-table commit_staged calls are independent
operations: if commit_staged on table N+1 fails after commit_staged
on tables 1..N succeeded, or if the publisher's CAS pre-check rejects
after every commit_staged succeeded, tables 1..N are left at
Lance HEAD = manifest_pinned + 1. The next mutation against those
tables surfaces ManifestConflictDetails::ExpectedVersionMismatch —
the same loud failure mode the rewire was designed to make rare, just
no longer "unreachable."
Triggers: transient Lance write errors during finalize (object-store
retry budget exhaustion, disk full); persistent publisher contention
exceeding PUBLISHER_RETRY_BUDGET = 5 retries. Closing this requires
either a Lance multi-dataset atomic-commit primitive (filed upstream
alongside the two-phase delete request) or a manifest-layer journal
that replays staged commits on next open. Both are heavyweight; the
v1 stance is "narrowed window, documented residual, surface the loud
error when it fires."
The publisher-CAS contract is unchanged: a concurrent writer that advances any of our touched tables between snapshot capture and publisher commit produces exactly one winner. The residual above is about our abandoned commits in the failure path, not about concurrency races.
Conflict shape
Concurrent writers to the same (table, branch) produce exactly one
success and one failure. The losing writer's error is
OmniError::Manifest with kind Conflict and details
ManifestConflictDetails::ExpectedVersionMismatch { table_key, expected, actual }. The HTTP server maps this to 409 Conflict with body
{"error": "...", "code": "conflict", "manifest_conflict": { "table_key": "...", "expected": N, "actual": M }} — see docs/server.md.
Audit
actor_id lands in _graph_commits.lance via record_graph_commit (no
intermediate run record). Audit history is queried via omnigraph commit list.
Migration code
db/manifest/migrations.rs does not change. Active deletion of
_graph_runs.lance belongs in MR-770 (the production sweep) — this PR
stops creating run state but does not destroy legacy bytes on disk.
Mid-query partial failure: closed by MR-794
The pre-MR-794 design had a known limitation: a multi-statement .gq
mutation where op-N inline-committed a Lance fragment and op-N+1 then
failed left the touched table at Lance HEAD = manifest_version + 1,
blocking the next mutation with ExpectedVersionMismatch.
MR-794 (step 1 + step 2+) closed this for inserts/updates by
construction at the writer layer: insert and update batches accumulate
in memory; no Lance HEAD advance happens during op execution; one
stage_* + commit_staged per touched table runs at end-of-query, and
only after every op succeeded. A failed op leaves Lance HEAD untouched
on the staged tables, so the next mutation proceeds normally with no
drift to reconcile.
The cancellation case (future drop mid-mutation) inherits the same guarantee — the in-memory accumulator evaporates with the dropped task and no Lance write was ever issued.
For delete-touching mutations the legacy inline-commit shape is
preserved (Lance has no public two-phase delete in 4.0.0) — the same
narrow window remains. The parse-time D₂ rule prevents inserts/updates
from coexisting with deletes in one query, so a pure-delete failure
cannot drift any staged-table state. If a delete-only multi-table
mutation fails mid-cascade, the same workaround as before applies
(retry; rely on omnigraph cleanup once a later successful commit
moves HEAD past the orphan version). Closing this requires Lance to
expose DeleteJob::execute_uncommitted; tracked in MR-793 and a
Lance-upstream ticket.