Enroll branch merge in RFC-022 write path (#345)

This commit is contained in:
Andrew Altshuler 2026-07-11 19:02:20 +03:00 committed by GitHub
parent bd4c614e42
commit e0e145aa92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3722 additions and 464 deletions

View file

@ -253,7 +253,7 @@ omnigraph policy explain --cluster ./company-brain --graph knowledge --actor act
| Columnar storage on object store | ✅ Arrow/Lance | URI normalization, S3 env-var plumbing | | Columnar storage on object store | ✅ Arrow/Lance | URI normalization, S3 env-var plumbing |
| Per-dataset versioning + time travel | ✅ | `snapshot_at_version`, `entity_at`, snapshot-pinned reads across many tables | | Per-dataset versioning + time travel | ✅ | `snapshot_at_version`, `entity_at`, snapshot-pinned reads across many tables |
| Per-dataset branches | ✅ | **Graph-level** refs are logically atomic through authoritative `__manifest` `BranchContents`; native create/delete crash gaps are classified and reclaimed under a single-writer-process boundary; live names are path-prefix-disjoint; data-table forks are lazy; system branches are filtered | | Per-dataset branches | ✅ | **Graph-level** refs are logically atomic through authoritative `__manifest` `BranchContents`; native create/delete crash gaps are classified and reclaimed under a single-writer-process boundary; live names are path-prefix-disjoint; data-table forks are lazy; system branches are filtered |
| Atomic single-dataset commits | ✅ | **Multi-table publish via three layers**, NOT a single Lance primitive: (1) per-table Lance `commit_staged` for the data write, (2) `__manifest` row-level CAS via `ManifestBatchPublisher` for cross-table ordering, (3) the open-time recovery sweep for the residual gap between (1) and (2). All three layers ship; the five migrated writers (`MutationStaging::commit_all`, `schema_apply`, `branch_merge`, `ensure_indices`, `optimize_all_tables`) write a `__recovery/{ulid}.json` sidecar before Phase B and delete it after Phase C. The next `Omnigraph::open` (gated on `OpenMode::ReadWrite`) runs the sweep in `db/manifest/recovery.rs`: classify, decide all-or-nothing per sidecar, roll forward via single `ManifestBatchPublisher::publish` or roll back via `Dataset::restore` followed by a manifest publish of the restored version (so both directions converge to `manifest == HEAD` — no residual drift), and record an internal audit row in `_graph_commit_recoveries.lance`. A v3 roll-forward preserves the interrupted writer's fixed commit lineage and actor; rollback and legacy recovery commits use `omnigraph:recovery`. There is currently no public CLI query for the recovery-audit table, and ordinary commit history is not a complete recovery enumeration. The write entry points (`load_as`, `mutate_as`, `apply_schema_as`, `branch_merge_as`) and `refresh` additionally run an in-process roll-forward-only heal, serialized against same-process live writers through the shared root-scoped gate manager, so a long-lived server converges on its next write without restart; only rollback-eligible sidecars still defer to the next read-write open (a future background reconciler's goal). Engine writes route through a sealed `TableStorage` trait (`db.storage()`) exposing only `stage_*` + `commit_staged` + reads; the sole inline-commit residual (`create_vector_index`) is split onto a separate sealed `InlineCommitResidual` trait reached via `db.storage_inline_residual()` (MR-854), so the default surface cannot couple a write with a HEAD advance — §1 holds by construction. `delete` migrated to the staged path in MR-A (`stage_delete` via Lance 7.0 `DeleteBuilder::execute_uncommitted`, [#6658](https://github.com/lance-format/lance/issues/6658)); `create_vector_index` stays inline until upstream Lance ships a public two-phase API ([#6666](https://github.com/lance-format/lance/issues/6666)); `LoadMode::Overwrite` uses Lance `Overwrite` staged transactions. | | Atomic single-dataset commits | ✅ | **Multi-table publish via three layers**, NOT a single Lance primitive: (1) per-table Lance `commit_staged` for the data write, (2) `__manifest` row-level CAS via `ManifestBatchPublisher` for cross-table ordering, (3) the open-time recovery sweep for the residual gap between (1) and (2). All three layers ship; the five migrated writers (`MutationStaging::commit_all`, `schema_apply`, `branch_merge`, `ensure_indices`, `optimize_all_tables`) write a `__recovery/{ulid}.json` sidecar before Phase B and delete it after Phase C. The next `Omnigraph::open` (gated on `OpenMode::ReadWrite`) runs the sweep in `db/manifest/recovery.rs`: classify, decide all-or-nothing per sidecar, roll forward via single `ManifestBatchPublisher::publish` or roll back via `Dataset::restore` followed by a manifest publish of the restored version (so both directions converge to `manifest == HEAD` — no residual drift), and record an internal audit row in `_graph_commit_recoveries.lance`. Schema-v3 Mutation/Load and schema-v4 BranchMerge roll-forward preserve the interrupted writer's fixed commit lineage and actor; rollback and legacy recovery commits use `omnigraph:recovery`. BranchMerge v4 also owns first-touch refs, pre-mints each table's exact ordered data-transaction chain with zero transparent conflict retries, carries pointer-only updates in its complete confirmed manifest delta, and recognizes an interrupted compensation restore on restart. There is currently no public CLI query for the recovery-audit table, and ordinary commit history is not a complete recovery enumeration. The write entry points (`load_as`, `mutate_as`, `apply_schema_as`, `branch_merge_as`) and `refresh` additionally run an in-process roll-forward-only heal, serialized against same-process live writers through the shared root-scoped gate manager, so a long-lived server converges on its next write without restart; only rollback-eligible sidecars still defer to the next read-write open (a future background reconciler's goal). Engine writes route through a sealed `TableStorage` trait (`db.storage()`) exposing only `stage_*` + `commit_staged` + reads; the sole inline-commit residual (`create_vector_index`) is split onto a separate sealed `InlineCommitResidual` trait reached via `db.storage_inline_residual()` (MR-854), so the default surface cannot couple a write with a HEAD advance — §1 holds by construction. `delete` migrated to the staged path in MR-A (`stage_delete` via Lance 7.0 `DeleteBuilder::execute_uncommitted`, [#6658](https://github.com/lance-format/lance/issues/6658)); `create_vector_index` stays inline until upstream Lance ships a public two-phase API ([#6666](https://github.com/lance-format/lance/issues/6666)); `LoadMode::Overwrite` uses Lance `Overwrite` staged transactions. |
| Compaction (`compact_files`) + reindex (`optimize_indices`) | ✅ | `omnigraph optimize` orchestrates over all node/edge tables, bounded concurrency; per table runs `compact_files` **then Lance `optimize_indices`** (folds appended/rewritten fragments back into existing indexes — incremental merge, not retrain) and **publishes the resulting version to `__manifest`** (so the manifest tracks the Lance HEAD — required for reads to observe the work and for schema apply / strict writes to pass their HEAD-vs-manifest precondition), under the per-`(table, main)` write queue with `SidecarKind::Optimize` recovery coverage spanning both ops; **commits even with no compaction work if index coverage is stale**; **refuses on an unrecovered graph**; **skips uncovered HEAD > manifest drift** with `DriftNeedsRepair`; **compacts blob-bearing tables** (the pre-9 `LANCE_SUPPORTS_BLOB_COMPACTION` skip was removed once Lance 8.0.0+ shipped blob-v2 compaction — see [docs/dev/invariants.md](docs/dev/invariants.md) Known Gaps) | | Compaction (`compact_files`) + reindex (`optimize_indices`) | ✅ | `omnigraph optimize` orchestrates over all node/edge tables, bounded concurrency; per table runs `compact_files` **then Lance `optimize_indices`** (folds appended/rewritten fragments back into existing indexes — incremental merge, not retrain) and **publishes the resulting version to `__manifest`** (so the manifest tracks the Lance HEAD — required for reads to observe the work and for schema apply / strict writes to pass their HEAD-vs-manifest precondition), under the per-`(table, main)` write queue with `SidecarKind::Optimize` recovery coverage spanning both ops; **commits even with no compaction work if index coverage is stale**; **refuses on an unrecovered graph**; **skips uncovered HEAD > manifest drift** with `DriftNeedsRepair`; **compacts blob-bearing tables** (the pre-9 `LANCE_SUPPORTS_BLOB_COMPACTION` skip was removed once Lance 8.0.0+ shipped blob-v2 compaction — see [docs/dev/invariants.md](docs/dev/invariants.md) Known Gaps) |
| Repair uncovered drift | — | `omnigraph repair` explicitly classifies uncovered table `HEAD > manifest` drift: verified maintenance drift (`ReserveFragments`/`Rewrite`) can be published with `--confirm`; suspicious or unverifiable drift requires `--force --confirm`. Sidecar-covered crash residuals still recover automatically on open. | | Repair uncovered drift | — | `omnigraph repair` explicitly classifies uncovered table `HEAD > manifest` drift: verified maintenance drift (`ReserveFragments`/`Rewrite`) can be published with `--confirm`; suspicious or unverifiable drift requires `--force --confirm`. Sidecar-covered crash residuals still recover automatically on open. |
| Cleanup (`cleanup_old_versions`) | ✅ | `omnigraph cleanup` derives requested `--keep` / `--older-than` cutoffs from each table's available versions; Lance refs plus OmniGraph's live-lazy-branch and recovery floors may retain additional versions. It fails closed on unopenable pins, recovery intent, or uncovered main-table HEAD drift | | Cleanup (`cleanup_old_versions`) | ✅ | `omnigraph cleanup` derives requested `--keep` / `--older-than` cutoffs from each table's available versions; Lance refs plus OmniGraph's live-lazy-branch and recovery floors may retain additional versions. It fails closed on unopenable pins, recovery intent, or uncovered main-table HEAD drift |

View file

@ -134,6 +134,45 @@ impl CommitGraph {
None => return Ok(None), None => return Ok(None),
}; };
Self::merge_base_from_open_graphs(
source,
target,
&source_head.graph_commit_id,
&target_head.graph_commit_id,
)
.await
}
/// Compute a merge base for two already-captured graph commits rather than
/// re-reading each branch's live head. Branch merge uses this after it has
/// captured immutable source/target authority: a later source advance must
/// not silently substitute a newer source commit, and a later target
/// advance is rejected by the target OCC token instead of changing the
/// classifier input.
pub(crate) async fn merge_base_between(
root_uri: &str,
source_branch: Option<&str>,
target_branch: Option<&str>,
source_commit_id: &str,
target_commit_id: &str,
) -> Result<Option<GraphCommit>> {
let source = open_for_branch(root_uri, source_branch).await?;
let target = open_for_branch(root_uri, target_branch).await?;
Self::merge_base_from_open_graphs(
source,
target,
source_commit_id,
target_commit_id,
)
.await
}
async fn merge_base_from_open_graphs(
source: Self,
target: Self,
source_commit_id: &str,
target_commit_id: &str,
) -> Result<Option<GraphCommit>> {
let mut commits = HashMap::new(); let mut commits = HashMap::new();
for commit in source.load_commits().await? { for commit in source.load_commits().await? {
commits.insert(commit.graph_commit_id.clone(), commit); commits.insert(commit.graph_commit_id.clone(), commit);
@ -142,8 +181,12 @@ impl CommitGraph {
commits.insert(commit.graph_commit_id.clone(), commit); commits.insert(commit.graph_commit_id.clone(), commit);
} }
let source_distances = ancestor_distances(&source_head.graph_commit_id, &commits); if !commits.contains_key(source_commit_id) || !commits.contains_key(target_commit_id) {
let target_distances = ancestor_distances(&target_head.graph_commit_id, &commits); return Ok(None);
}
let source_distances = ancestor_distances(source_commit_id, &commits);
let target_distances = ancestor_distances(target_commit_id, &commits);
let best = source_distances let best = source_distances
.iter() .iter()

View file

@ -447,30 +447,6 @@ impl GraphCoordinator {
}) })
} }
/// Publish a branch-merge: `updates` (the merged table versions) plus the
/// merge commit, in one manifest CAS (RFC-013 Phase 7). The merge commit's
/// merged-in parent is `merged_parent_commit_id` (the source head, stable);
/// its first parent is resolved by the publisher as the current target-branch
/// head — the live head, which is the post-merge correct parent even if the
/// target advanced since the merge began.
pub(crate) async fn commit_merge_with_actor(
&mut self,
updates: &[SubTableUpdate],
merged_parent_commit_id: &str,
actor_id: Option<&str>,
) -> Result<SnapshotId> {
let intent =
self.new_lineage_intent(actor_id, Some(merged_parent_commit_id.to_string()))?;
failpoints::maybe_fail(crate::failpoints::names::GRAPH_PUBLISH_BEFORE_COMMIT_APPEND)?;
let changes = updates_to_changes(updates);
let outcome = self
.manifest
.commit_changes_with_lineage(&changes, &HashMap::new(), Some(&intent))
.await?;
failpoints::maybe_fail(crate::failpoints::names::GRAPH_PUBLISH_AFTER_MANIFEST_COMMIT)?;
Ok(self.apply_lineage_to_cache(intent, &outcome))
}
/// Mint a [`LineageIntent`] for the next commit on the current branch: a /// Mint a [`LineageIntent`] for the next commit on the current branch: a
/// fresh ULID (stable across the publisher's CAS retries) and a timestamp. /// fresh ULID (stable across the publisher's CAS retries) and a timestamp.
/// The parent is NOT chosen here — the publisher resolves it per attempt /// The parent is NOT chosen here — the publisher resolves it per attempt

View file

@ -38,10 +38,12 @@ use namespace::{branch_manifest_namespace, staged_table_namespace};
pub(crate) use publisher::{GraphHeadExpectation, LineageIntent, PublishPrecondition}; pub(crate) use publisher::{GraphHeadExpectation, LineageIntent, PublishPrecondition};
use publisher::{GraphNamespacePublisher, ManifestBatchPublisher, PublishOutcome}; use publisher::{GraphNamespacePublisher, ManifestBatchPublisher, PublishOutcome};
pub(crate) use recovery::{ pub(crate) use recovery::{
HealPendingOutcome, RecoveryAuthorityToken, RecoveryLineageIntent, RecoveryMode, HealPendingOutcome, RecoveryAuthorityToken, RecoveryBranchMergeEffect,
RecoverySidecar, RecoverySidecarHandle, SidecarKind, SidecarTablePin, SidecarTableRegistration, RecoveryBranchMergeEffectKind, RecoveryLineageIntent, RecoveryManifestDelta, RecoveryMode,
SidecarTombstone, confirm_occ_sidecar_phase_b, confirm_sidecar_phase_b, delete_sidecar, RecoverySidecar, RecoverySidecarHandle, RecoveryTableUpdateSlot, SidecarKind, SidecarTablePin,
has_schema_apply_sidecar, heal_pending_sidecars_roll_forward, list_sidecars, new_occ_sidecar, SidecarTableRegistration, SidecarTombstone, confirm_branch_merge_sidecar_phase_b,
confirm_occ_sidecar_phase_b, delete_sidecar, has_schema_apply_sidecar,
heal_pending_sidecars_roll_forward, list_sidecars, new_branch_merge_sidecar, new_occ_sidecar,
new_sidecar, recover_manifest_drift, schema_apply_serial_queue_key, write_sidecar, new_sidecar, recover_manifest_drift, schema_apply_serial_queue_key, write_sidecar,
}; };
pub use state::SubTableEntry; pub use state::SubTableEntry;

File diff suppressed because it is too large Load diff

View file

@ -131,6 +131,11 @@ pub(crate) struct WriteTxn {
pub(crate) base: Snapshot, pub(crate) base: Snapshot,
/// Complete coarse authority token for this prepared attempt. /// Complete coarse authority token for this prepared attempt.
pub(crate) authority: WriteAuthorityToken, pub(crate) authority: WriteAuthorityToken,
/// Effective lineage head of the captured branch snapshot. Unlike
/// `authority.graph_head`, this includes an inherited head on a freshly
/// forked named branch whose materialized `graph_head:<branch>` row is
/// intentionally absent.
pub(crate) effective_graph_head: Option<String>,
/// Catalog built from the exact accepted IR whose identity is recorded in /// Catalog built from the exact accepted IR whose identity is recorded in
/// `authority`. Mutation/load planning and validation must use this snapshot, /// `authority`. Mutation/load planning and validation must use this snapshot,
/// never the handle-global catalog, which can lag a schema apply performed by /// never the handle-global catalog, which can lag a schema apply performed by
@ -894,7 +899,7 @@ impl Omnigraph {
.await?; .await?;
let (schema_ir, schema_state) = let (schema_ir, schema_state) =
load_validated_schema_contract(self.uri(), Arc::clone(&self.storage)).await?; load_validated_schema_contract(self.uri(), Arc::clone(&self.storage)).await?;
let (branch_identifier, graph_head, snapshot) = self let (branch_identifier, graph_head, effective_graph_head, snapshot) = self
.write_authority_for_known_branch(branch.as_deref(), true) .write_authority_for_known_branch(branch.as_deref(), true)
.await?; .await?;
self.ensure_schema_apply_not_locked("write preparation") self.ensure_schema_apply_not_locked("write preparation")
@ -918,6 +923,7 @@ impl Omnigraph {
schema_ir_hash: schema_state.schema_ir_hash, schema_ir_hash: schema_state.schema_ir_hash,
schema_identity_version: schema_state.schema_identity_version, schema_identity_version: schema_state.schema_identity_version,
}, },
effective_graph_head,
catalog: Arc::new(catalog), catalog: Arc::new(catalog),
}); });
} }
@ -972,6 +978,7 @@ impl Omnigraph {
) -> Result<( ) -> Result<(
lance::dataset::refs::BranchIdentifier, lance::dataset::refs::BranchIdentifier,
Option<String>, Option<String>,
Option<String>,
Snapshot, Snapshot,
)> { )> {
{ {
@ -987,6 +994,10 @@ impl Omnigraph {
return Ok(( return Ok((
coord.branch_identifier().await?, coord.branch_identifier().await?,
coord.exact_graph_head(), coord.exact_graph_head(),
coord
.head_commit_id()
.await?
.map(|head| head.as_str().to_string()),
coord.snapshot(), coord.snapshot(),
)); ));
} }
@ -997,6 +1008,10 @@ impl Omnigraph {
Ok(( Ok((
coord.branch_identifier().await?, coord.branch_identifier().await?,
coord.exact_graph_head(), coord.exact_graph_head(),
coord
.head_commit_id()
.await?
.map(|head| head.as_str().to_string()),
coord.snapshot(), coord.snapshot(),
)) ))
} }
@ -1008,7 +1023,7 @@ impl Omnigraph {
// Recheck the durable sentinel inside that critical section so a schema // Recheck the durable sentinel inside that critical section so a schema
// apply observed after preparation cannot be followed by a table effect. // apply observed after preparation cannot be followed by a table effect.
self.ensure_schema_apply_not_locked("write commit").await?; self.ensure_schema_apply_not_locked("write commit").await?;
let (branch_identifier, graph_head, snapshot) = self let (branch_identifier, graph_head, _effective_graph_head, snapshot) = self
.write_authority_for_known_branch(txn.branch.as_deref(), true) .write_authority_for_known_branch(txn.branch.as_deref(), true)
.await?; .await?;
let schema_state = self.ensure_schema_state_valid().await?; let schema_state = self.ensure_schema_state_valid().await?;
@ -1969,17 +1984,6 @@ impl Omnigraph {
normalize_branch_name(branch) normalize_branch_name(branch)
} }
pub(crate) async fn head_commit_id_for_branch(
&self,
branch: Option<&str>,
) -> Result<Option<String>> {
let coordinator = self.open_coordinator_for_branch(branch).await?;
coordinator
.head_commit_id()
.await
.map(|id| id.map(|snapshot_id| snapshot_id.as_str().to_string()))
}
pub async fn branch_create(&self, name: &str) -> Result<()> { pub async fn branch_create(&self, name: &str) -> Result<()> {
self.branch_create_as(name, None).await self.branch_create_as(name, None).await
} }
@ -2357,19 +2361,6 @@ impl Omnigraph {
table_ops::commit_updates(self, updates).await table_ops::commit_updates(self, updates).await
} }
/// Publish a branch merge: the merged table `updates` and the merge commit
/// in one manifest CAS (RFC-013 Phase 7). The merge commit's merged-in parent
/// is `merged_parent_commit_id` (the source head); its first parent is the
/// live target-branch head, resolved by the publisher.
pub(crate) async fn commit_merge_with_actor(
&self,
updates: &[crate::db::SubTableUpdate],
merged_parent_commit_id: &str,
actor_id: Option<&str>,
) -> Result<String> {
table_ops::commit_merge_with_actor(self, updates, merged_parent_commit_id, actor_id).await
}
pub(crate) async fn commit_updates_on_branch_with_expected( pub(crate) async fn commit_updates_on_branch_with_expected(
&self, &self,
branch: Option<&str>, branch: Option<&str>,

View file

@ -1460,20 +1460,6 @@ pub(super) async fn commit_updates(
commit_prepared_updates(db, &prepared, None).await commit_prepared_updates(db, &prepared, None).await
} }
pub(super) async fn commit_merge_with_actor(
db: &Omnigraph,
updates: &[crate::db::SubTableUpdate],
merged_parent_commit_id: &str,
actor_id: Option<&str>,
) -> Result<String> {
db.coordinator
.write()
.await
.commit_merge_with_actor(updates, merged_parent_commit_id, actor_id)
.await
.map(|snapshot_id| snapshot_id.as_str().to_string())
}
/// Commit updates with a publisher-level OCC fence. The /// Commit updates with a publisher-level OCC fence. The
/// `expected_table_versions` map asserts the manifest's pre-write per-table /// `expected_table_versions` map asserts the manifest's pre-write per-table
/// versions; mismatches surface as `ManifestConflictDetails::ExpectedVersionMismatch`. /// versions; mismatches surface as `ManifestConflictDetails::ExpectedVersionMismatch`.

View file

@ -918,14 +918,84 @@ async fn publish_adopted_source_state(
} }
} }
fn pre_minted_merge_transaction(
read_version: u64,
) -> crate::table_store::StagedTransactionIdentity {
crate::table_store::StagedTransactionIdentity {
read_version,
uuid: format!("omnigraph-merge-{}", ulid::Ulid::new()),
}
}
fn plan_merge_transactions(
table_key: &str,
candidate: &CandidateTableState,
first_read_version: u64,
) -> Result<Vec<crate::table_store::StagedTransactionIdentity>> {
let transaction_count = match candidate {
CandidateTableState::RewriteMerged(staged) => {
usize::from(staged.delta_staged.is_some()) + usize::from(!staged.deleted_ids.is_empty())
}
CandidateTableState::AdoptWithDelta(delta) => {
usize::from(delta.appends.is_some())
+ usize::from(delta.upserts.is_some())
+ usize::from(!delta.deleted_ids.is_empty())
}
CandidateTableState::AdoptSourceState { .. } => 0,
};
if transaction_count == 0 {
return Err(OmniError::manifest_internal(format!(
"HEAD-advancing branch merge candidate '{table_key}' has no logical data transaction"
)));
}
(0..transaction_count)
.map(|offset| {
let offset = u64::try_from(offset).map_err(|_| {
OmniError::manifest_internal(format!(
"branch merge transaction count for '{table_key}' exceeds u64"
))
})?;
let read_version = first_read_version.checked_add(offset).ok_or_else(|| {
OmniError::manifest_internal(format!(
"branch merge transaction version overflow for '{table_key}'"
))
})?;
Ok(pre_minted_merge_transaction(read_version))
})
.collect()
}
async fn commit_exact_merge_stage(
target_db: &Omnigraph,
current: SnapshotHandle,
mut staged: crate::storage_layer::StagedHandle,
planned: &crate::table_store::StagedTransactionIdentity,
) -> Result<SnapshotHandle> {
staged.bind_transaction_identity(planned)?;
let outcome = target_db
.storage()
.commit_staged_exact(current, staged)
.await?;
if !outcome.is_exact() {
return Err(OmniError::manifest_read_set_changed(
"branch_merge_lance_transaction",
Some(format!("{:?}", outcome.planned_transaction())),
Some(format!("{:?}", outcome.committed_transaction())),
));
}
Ok(outcome.into_snapshot())
}
async fn publish_rewritten_merge_table( async fn publish_rewritten_merge_table(
target_db: &Omnigraph, target_db: &Omnigraph,
table_key: &str, table_key: &str,
staged: &StagedMergeResult, staged: &StagedMergeResult,
planned_transactions: &[crate::table_store::StagedTransactionIdentity],
) -> Result<crate::db::SubTableUpdate> { ) -> Result<crate::db::SubTableUpdate> {
// Branch merge's source-rewrite path is Merge-shaped (upsert from // Branch merge's source-rewrite path is Merge-shaped (upsert from
// source onto target). The staged delete later in this function // source onto target). The staged delete later in this function
// (`stage_delete` + `commit_staged`) operates on rows the rewrite chose // (`stage_delete` + an exact commit) operates on rows the rewrite chose
// to remove, not user-facing predicates, so Merge is the correct policy // to remove, not user-facing predicates, so Merge is the correct policy
// here. // here.
// `open_for_mutation` is the no-txn entry, so collapse #1's non-strict // `open_for_mutation` is the no-txn entry, so collapse #1's non-strict
@ -939,12 +1009,11 @@ async fn publish_rewritten_merge_table(
// Phase 1: merge_insert changed/new rows (preserves _row_created_at_version for // Phase 1: merge_insert changed/new rows (preserves _row_created_at_version for
// existing rows, bumps _row_last_updated_at_version only for actually-changed rows). // existing rows, bumps _row_last_updated_at_version only for actually-changed rows).
// //
// Routed through the staged primitive so a failure between writing // Routed through the staged primitive with the transaction identity armed
// fragments and committing leaves no Lance-HEAD drift. The // in the v4 sidecar and transparent Lance conflict retries disabled. A
// commit_staged here is per-table per-call (Lance has no // failure between writing fragments and committing leaves no Lance-HEAD
// multi-dataset atomic commit); the residual sits at this single // drift; a failure after the exact commit remains recovery-owned.
// commit point, narrowed from the previous "merge_insert + delete + let mut planned_transactions = planned_transactions.iter();
// index" multi-step inline-commit chain.
if let Some(delta) = &staged.delta_staged { if let Some(delta) = &staged.delta_staged {
// The staged delta dataset is a temp-dir Lance dataset used only // The staged delta dataset is a temp-dir Lance dataset used only
// to collect the rewrite batches; wrap it in a `SnapshotHandle` // to collect the rewrite batches; wrap it in a `SnapshotHandle`
@ -976,10 +1045,13 @@ async fn publish_rewritten_merge_table(
lance::dataset::WhenNotMatched::InsertAll, lance::dataset::WhenNotMatched::InsertAll,
) )
.await?; .await?;
current_ds = target_db let planned = planned_transactions.next().ok_or_else(|| {
.storage() OmniError::manifest_internal(format!(
.commit_staged(current_ds, staged_merge) "branch merge table '{table_key}' has no planned merge transaction"
.await?; ))
})?;
current_ds =
commit_exact_merge_stage(target_db, current_ds, staged_merge, planned).await?;
} }
} }
@ -988,10 +1060,12 @@ async fn publish_rewritten_merge_table(
// rows are on Lance HEAD but the delete has not committed and the // rows are on Lance HEAD but the delete has not committed and the
// achieved-version intent has not been recorded, so recovery must roll BACK. // achieved-version intent has not been recorded, so recovery must roll BACK.
// See tests/failpoints.rs::branch_merge_rewrite_partial_after_merge_rolls_back. // See tests/failpoints.rs::branch_merge_rewrite_partial_after_merge_rolls_back.
crate::failpoints::maybe_fail(crate::failpoints::names::BRANCH_MERGE_REWRITE_AFTER_MERGE_PRE_DELETE)?; crate::failpoints::maybe_fail(
crate::failpoints::names::BRANCH_MERGE_REWRITE_AFTER_MERGE_PRE_DELETE,
)?;
// Phase 2: delete removed rows via deletion vectors, staged through // Phase 2: delete removed rows via deletion vectors, staged through
// `stage_delete` + `commit_staged` (MR-A — Lance 7.0's // `stage_delete` + an exact pre-minted commit (MR-A — Lance 7.0's
// `DeleteBuilder::execute_uncommitted`, #6658, made delete a two-phase // `DeleteBuilder::execute_uncommitted`, #6658, made delete a two-phase
// staged write, so this no longer inline-commits). // staged write, so this no longer inline-commits).
if !staged.deleted_ids.is_empty() { if !staged.deleted_ids.is_empty() {
@ -1001,27 +1075,45 @@ async fn publish_rewritten_merge_table(
.map(|id| format!("'{}'", id.replace('\'', "''"))) .map(|id| format!("'{}'", id.replace('\'', "''")))
.collect(); .collect();
let filter = format!("id IN ({})", escaped.join(", ")); let filter = format!("id IN ({})", escaped.join(", "));
if let Some(staged_delete) = target_db.storage().stage_delete(&current_ds, &filter).await? { if let Some(staged_delete) = target_db
current_ds = target_db .storage()
.storage() .stage_delete(&current_ds, &filter)
.commit_staged(current_ds, staged_delete) .await?
.await?; {
let planned = planned_transactions.next().ok_or_else(|| {
OmniError::manifest_internal(format!(
"branch merge table '{table_key}' has no planned delete transaction"
))
})?;
current_ds =
commit_exact_merge_stage(target_db, current_ds, staged_delete, planned).await?;
} }
} }
if let Some(unused) = planned_transactions.next() {
return Err(OmniError::manifest_internal(format!(
"branch merge table '{table_key}' did not apply planned transaction {unused:?}"
)));
}
// Failpoint: crash after the Phase 2 delete commit, before the index build. // Failpoint: crash after the Phase 2 delete commit, before the index build.
// Models a partial Phase B on the three-way path — constructive rows + // Models a partial Phase B on the three-way path — constructive rows +
// deletes are on Lance HEAD but the achieved-version intent has not been // deletes are on Lance HEAD but the achieved-version intent has not been
// recorded, so recovery must roll BACK (the index is reconciler-owned derived // recorded, so recovery must roll BACK (the index is reconciler-owned derived
// state, but the merge itself never reached its commit boundary). See // state, but the merge itself never reached its commit boundary). See
// tests/failpoints.rs::branch_merge_rewrite_partial_after_delete_rolls_back. // tests/failpoints.rs::branch_merge_rewrite_partial_after_delete_rolls_back.
crate::failpoints::maybe_fail(crate::failpoints::names::BRANCH_MERGE_REWRITE_AFTER_DELETE_PRE_INDEX)?; crate::failpoints::maybe_fail(
crate::failpoints::names::BRANCH_MERGE_REWRITE_AFTER_DELETE_PRE_INDEX,
)?;
// Phase 3: rebuild indices. // Phase 3: rebuild indices.
// //
// `build_indices_on_dataset` uses `stage_create_btree_index` / // `build_indices_on_dataset` uses `stage_create_btree_index` /
// `stage_create_inverted_index` + `commit_staged` for scalar // `stage_create_inverted_index` + `commit_staged` for scalar indices.
// indices. Vector indices remain inline-commit // These are rebuildable `CreateIndex` tail transactions, not part of the
// merge's logical pre-minted data chain; Armed v4 recovery accepts them
// only after that complete exact chain and discards them with a rollback.
// Vector indices remain inline-commit
// (`build_index_metadata_from_segments` is `pub(crate)` in lance- // (`build_index_metadata_from_segments` is `pub(crate)` in lance-
// 6.0.1 — companion ticket to lance-format/lance#6666). // 6.0.1 — companion ticket to lance-format/lance#6666).
let row_count = target_db let row_count = target_db
@ -1095,6 +1187,7 @@ async fn publish_adopted_delta(
target_db: &Omnigraph, target_db: &Omnigraph,
table_key: &str, table_key: &str,
delta: &AdoptDelta, delta: &AdoptDelta,
planned_transactions: &[crate::table_store::StagedTransactionIdentity],
) -> Result<crate::db::SubTableUpdate> { ) -> Result<crate::db::SubTableUpdate> {
// `open_for_mutation` is the no-txn entry, so collapse #1's non-strict // `open_for_mutation` is the no-txn entry, so collapse #1's non-strict
// open-skip (gated on `txn.is_some()`) never fires here — the handle is // open-skip (gated on `txn.is_some()`) never fires here — the handle is
@ -1115,16 +1208,19 @@ async fn publish_adopted_delta(
// through the staged primitive so a failure between writing fragments and // through the staged primitive so a failure between writing fragments and
// committing leaves no Lance-HEAD drift. `appends` is `Some` only when the // committing leaves no Lance-HEAD drift. `appends` is `Some` only when the
// staged table is non-empty (`compute_adopt_delta`). // staged table is non-empty (`compute_adopt_delta`).
let mut planned_transactions = planned_transactions.iter();
if let Some(append_table) = &delta.appends { if let Some(append_table) = &delta.appends {
let source = SnapshotHandle::new(append_table.dataset.clone()); let source = SnapshotHandle::new(append_table.dataset.clone());
let staged = target_db let staged = target_db
.storage() .storage()
.stage_append_stream(&current_ds, &source, &[]) .stage_append_stream(&current_ds, &source, &[])
.await?; .await?;
current_ds = target_db let planned = planned_transactions.next().ok_or_else(|| {
.storage() OmniError::manifest_internal(format!(
.commit_staged(current_ds, staged) "branch merge table '{table_key}' has no planned append transaction"
.await?; ))
})?;
current_ds = commit_exact_merge_stage(target_db, current_ds, staged, planned).await?;
} }
// Failpoint: crash after the Phase 1a append commit, before the upsert. // Failpoint: crash after the Phase 1a append commit, before the upsert.
@ -1132,14 +1228,17 @@ async fn publish_adopted_delta(
// have not committed and the achieved-version intent has not been recorded, so // have not committed and the achieved-version intent has not been recorded, so
// recovery must roll BACK (not publish the appends-only state). See // recovery must roll BACK (not publish the appends-only state). See
// tests/failpoints.rs::branch_merge_adopt_partial_after_append_rolls_back. // tests/failpoints.rs::branch_merge_adopt_partial_after_append_rolls_back.
crate::failpoints::maybe_fail(crate::failpoints::names::BRANCH_MERGE_ADOPT_AFTER_APPEND_PRE_UPSERT)?; crate::failpoints::maybe_fail(
crate::failpoints::names::BRANCH_MERGE_ADOPT_AFTER_APPEND_PRE_UPSERT,
)?;
// Phase 1b: upsert the CHANGED rows. The merge_insert hash join is now // Phase 1b: upsert the CHANGED rows. The merge_insert hash join is now
// bounded to the genuinely-changed set, not the whole delta. It runs against // bounded to the genuinely-changed set, not the whole delta. It runs against
// the committed view that already includes the appends; the changed ids are // the committed view that already includes the appends; the changed ids are
// disjoint from the appended ids (each id is classified into exactly one of // disjoint from the appended ids (each id is classified into exactly one of
// new / changed / deleted / unchanged in the single ordered walk), so the // new / changed / deleted / unchanged in the single ordered walk), so the
// join never collides with an appended row. // join never collides with an appended row. Every logical data step uses
// the next identity in the exact transaction chain armed before Phase B.
if let Some(upsert_table) = &delta.upserts { if let Some(upsert_table) = &delta.upserts {
if let Some(combined) = scan_staged_combined(target_db, upsert_table).await? { if let Some(combined) = scan_staged_combined(target_db, upsert_table).await? {
let staged_merge = target_db let staged_merge = target_db
@ -1152,10 +1251,13 @@ async fn publish_adopted_delta(
lance::dataset::WhenNotMatched::InsertAll, lance::dataset::WhenNotMatched::InsertAll,
) )
.await?; .await?;
current_ds = target_db let planned = planned_transactions.next().ok_or_else(|| {
.storage() OmniError::manifest_internal(format!(
.commit_staged(current_ds, staged_merge) "branch merge table '{table_key}' has no planned upsert transaction"
.await?; ))
})?;
current_ds =
commit_exact_merge_stage(target_db, current_ds, staged_merge, planned).await?;
} }
} }
@ -1164,10 +1266,13 @@ async fn publish_adopted_delta(
// has not committed and the achieved-version intent has not been recorded, so // has not committed and the achieved-version intent has not been recorded, so
// recovery must roll BACK. See // recovery must roll BACK. See
// tests/failpoints.rs::branch_merge_adopt_partial_after_upsert_rolls_back. // tests/failpoints.rs::branch_merge_adopt_partial_after_upsert_rolls_back.
crate::failpoints::maybe_fail(crate::failpoints::names::BRANCH_MERGE_ADOPT_AFTER_UPSERT_PRE_DELETE)?; crate::failpoints::maybe_fail(
crate::failpoints::names::BRANCH_MERGE_ADOPT_AFTER_UPSERT_PRE_DELETE,
)?;
// Phase 2: delete removed rows via deletion vectors, staged through // Phase 2: delete removed rows via deletion vectors, staged through
// `stage_delete` + `commit_staged` (same as the three-way path; MR-A). // `stage_delete` + an exact pre-minted commit (same as the three-way path;
// MR-A).
if !delta.deleted_ids.is_empty() { if !delta.deleted_ids.is_empty() {
let escaped: Vec<String> = delta let escaped: Vec<String> = delta
.deleted_ids .deleted_ids
@ -1175,14 +1280,27 @@ async fn publish_adopted_delta(
.map(|id| format!("'{}'", id.replace('\'', "''"))) .map(|id| format!("'{}'", id.replace('\'', "''")))
.collect(); .collect();
let filter = format!("id IN ({})", escaped.join(", ")); let filter = format!("id IN ({})", escaped.join(", "));
if let Some(staged_delete) = target_db.storage().stage_delete(&current_ds, &filter).await? { if let Some(staged_delete) = target_db
current_ds = target_db .storage()
.storage() .stage_delete(&current_ds, &filter)
.commit_staged(current_ds, staged_delete) .await?
.await?; {
let planned = planned_transactions.next().ok_or_else(|| {
OmniError::manifest_internal(format!(
"branch merge table '{table_key}' has no planned delete transaction"
))
})?;
current_ds =
commit_exact_merge_stage(target_db, current_ds, staged_delete, planned).await?;
} }
} }
if let Some(unused) = planned_transactions.next() {
return Err(OmniError::manifest_internal(format!(
"branch merge table '{table_key}' did not apply planned transaction {unused:?}"
)));
}
// Phase 4: index coverage is reconciler-owned on the adopt path. Unlike the // Phase 4: index coverage is reconciler-owned on the adopt path. Unlike the
// three-way `RewriteMerged` path, this does NOT build indices inline: the // three-way `RewriteMerged` path, this does NOT build indices inline: the
// appended/upserted rows are left uncovered (reads stay correct via // appended/upserted rows are left uncovered (reads stay correct via
@ -1273,32 +1391,37 @@ impl Omnigraph {
.write_queue() .write_queue()
.acquire_branches(&[source_branch.clone(), target_branch.clone()]) .acquire_branches(&[source_branch.clone(), target_branch.clone()])
.await; .await;
self.ensure_no_pending_recovery_sidecars_under_gates( self.ensure_no_pending_recovery_sidecars_under_gates(&relevant_branches, "branch_merge")
&relevant_branches, .await?;
"branch_merge",
)
.await?;
self.refresh_coordinator_only().await?; self.refresh_coordinator_only().await?;
self.ensure_schema_apply_not_locked("branch_merge").await?; self.ensure_schema_apply_not_locked("branch_merge").await?;
let merge_catalog = self // Capture each branch as one coherent RFC-022 authority token plus
.load_accepted_catalog_with_schema_gate_held() // immutable snapshot. The target token is the coarse publish read set;
.await?; // the source token pins the exact merge input without requiring the
// source head to remain latest until the target CAS.
let source_head_commit_id = self let source_txn = self.open_write_txn(source_branch.as_deref()).await?;
.head_commit_id_for_branch(source_branch.as_deref()) let target_txn = self.open_write_txn(target_branch.as_deref()).await?;
.await? let source_head_commit_id = source_txn
.effective_graph_head
.clone()
.ok_or_else(|| OmniError::manifest("source branch has no head commit".to_string()))?; .ok_or_else(|| OmniError::manifest("source branch has no head commit".to_string()))?;
let target_head_commit_id = self let target_head_commit_id = target_txn
.head_commit_id_for_branch(target_branch.as_deref()) .effective_graph_head
.await? .clone()
.ok_or_else(|| OmniError::manifest("target branch has no head commit".to_string()))?; .ok_or_else(|| OmniError::manifest("target branch has no head commit".to_string()))?;
let base_commit = CommitGraph::merge_base( let base_commit = CommitGraph::merge_base_between(
self.uri(), self.uri(),
source_branch.as_deref(), source_branch.as_deref(),
target_branch.as_deref(), target_branch.as_deref(),
&source_head_commit_id,
&target_head_commit_id,
) )
.await? .await?
.ok_or_else(|| OmniError::manifest("branches have no common ancestor".to_string()))?; .ok_or_else(|| {
OmniError::manifest(
"captured branch commits are unavailable or have no common ancestor".to_string(),
)
})?;
if source_head_commit_id == target_head_commit_id if source_head_commit_id == target_head_commit_id
|| base_commit.graph_commit_id == source_head_commit_id || base_commit.graph_commit_id == source_head_commit_id
@ -1313,18 +1436,6 @@ impl Omnigraph {
base_commit.manifest_version, base_commit.manifest_version,
) )
.await?; .await?;
let source_snapshot = self
.resolved_target(ReadTarget::Branch(
source_branch.clone().unwrap_or_else(|| "main".to_string()),
))
.await?
.snapshot;
let target_snapshot = self
.resolved_target(ReadTarget::Branch(
target_branch.clone().unwrap_or_else(|| "main".to_string()),
))
.await?
.snapshot;
crate::failpoints::maybe_fail( crate::failpoints::maybe_fail(
crate::failpoints::names::BRANCH_MERGE_POST_AUTHORITY_CAPTURE, crate::failpoints::names::BRANCH_MERGE_POST_AUTHORITY_CAPTURE,
)?; )?;
@ -1345,9 +1456,8 @@ impl Omnigraph {
let merge_result = self let merge_result = self
.branch_merge_on_current_target( .branch_merge_on_current_target(
&base_snapshot, &base_snapshot,
&source_snapshot, &source_txn,
&target_snapshot, &target_txn,
merge_catalog.as_ref(),
source_branch.as_deref(), source_branch.as_deref(),
target_branch.as_deref(), target_branch.as_deref(),
&target_head_commit_id, &target_head_commit_id,
@ -1377,7 +1487,7 @@ impl Omnigraph {
// Use `refresh_coordinator_only` rather than `refresh` so the // Use `refresh_coordinator_only` rather than `refresh` so the
// recovery sweep doesn't race the merge's own in-flight // recovery sweep doesn't race the merge's own in-flight
// sidecar: when the merge body returns Err between Phase B // sidecar: when the merge body returns Err between Phase B
// (per-table `commit_staged` + sidecar write) and Phase C // (per-table exact commits + sidecar confirmation) and Phase C
// (manifest publish + sidecar delete), the sidecar is still on // (manifest publish + sidecar delete), the sidecar is still on
// disk. `refresh`'s `RollForwardOnly` sweep would observe it // disk. `refresh`'s `RollForwardOnly` sweep would observe it
// and close it here — masking the failure from the next // and close it here — masking the failure from the next
@ -1411,9 +1521,8 @@ impl Omnigraph {
async fn branch_merge_on_current_target( async fn branch_merge_on_current_target(
&self, &self,
base_snapshot: &Snapshot, base_snapshot: &Snapshot,
source_snapshot: &Snapshot, source_txn: &WriteTxn,
target_snapshot: &Snapshot, target_txn: &WriteTxn,
catalog: &Catalog,
source_branch: Option<&str>, source_branch: Option<&str>,
target_branch: Option<&str>, target_branch: Option<&str>,
target_head_commit_id: &str, target_head_commit_id: &str,
@ -1421,6 +1530,9 @@ impl Omnigraph {
is_fast_forward: bool, is_fast_forward: bool,
actor_id: Option<&str>, actor_id: Option<&str>,
) -> Result<MergeOutcome> { ) -> Result<MergeOutcome> {
let source_snapshot = &source_txn.base;
let target_snapshot = &target_txn.base;
let catalog = target_txn.catalog.as_ref();
let mut table_keys = HashSet::new(); let mut table_keys = HashSet::new();
for entry in base_snapshot.entries() { for entry in base_snapshot.entries() {
table_keys.insert(entry.table_key.clone()); table_keys.insert(entry.table_key.clone());
@ -1486,25 +1598,14 @@ impl Omnigraph {
let changeset = build_merge_changeset(self, catalog, &candidates).await?; let changeset = build_merge_changeset(self, catalog, &candidates).await?;
validate_merge_candidates(catalog, target_snapshot, &changeset).await?; validate_merge_candidates(catalog, target_snapshot, &changeset).await?;
// Recovery sidecar: protect the per-table commit_staged loop. // Recovery sidecar: protect the complete physical effect set. Every
// Pin `RewriteMerged` and `AdoptWithDelta` candidates — both advance // `RewriteMerged` / `AdoptWithDelta` logical data step receives a
// Lance HEAD before the manifest publish (RewriteMerged via // pre-minted, sequential Lance transaction identity and commits with
// publish_rewritten_merge_table; AdoptWithDelta via publish_adopted_delta: // transparent retries disabled. Ref-only `AdoptSourceState` candidates
// stage_append + stage_merge_insert + stage_delete + index — multiple // have no data transaction, but a first-touch native target ref is still
// commit_staged calls per table, which the loose classification handles // an independently durable v4 effect. Pointer-only manifest updates need
// as multi-step drift). // no physical pin; they remain in the complete intended delta so a
// // sibling effect's recovery publishes the merge atomically.
// `AdoptSourceState` candidates are NOT pinned: their publish
// (`publish_adopted_source_state`) is a pure pointer switch or a fork
// (`fork_dataset_from_entry_state` only adds a Lance branch ref), neither
// of which advances the data HEAD. Pinning them would classify as
// NoMovement and force an all-or-nothing rollback that destroys sibling
// tables' committed work.
//
// The former gap — adopt subcases that applied a non-empty delta advanced
// HEAD unpinned — is closed: `classify_adopt` pre-computes the delta, so a
// HEAD-advancing adopt is `AdoptWithDelta` (pinned here) and an empty-delta
// adopt stays `AdoptSourceState`.
// //
// This bridge still coexists with legacy maintenance writers that take // This bridge still coexists with legacy maintenance writers that take
// only `(table, branch)` queues. Acquire the conservative all-catalog // only `(table, branch)` queues. Acquire the conservative all-catalog
@ -1525,101 +1626,228 @@ impl Omnigraph {
"branch_merge after acquiring source/target table gates", "branch_merge after acquiring source/target table gates",
) )
.await?; .await?;
let fresh_source_snapshot = self
.fresh_snapshot_for_branch_unchecked(source_branch)
.await?;
let fresh_target_snapshot = self let fresh_target_snapshot = self
.fresh_snapshot_for_branch_unchecked(target_branch) .fresh_snapshot_for_branch_unchecked(target_branch)
.await?; .await?;
for (member, prepared, current) in [ if target_snapshot.version() != fresh_target_snapshot.version() {
("source", source_snapshot, &fresh_source_snapshot), return Err(OmniError::manifest_read_set_changed(
("target", target_snapshot, &fresh_target_snapshot), format!("branch_merge_target:{}", target_branch.unwrap_or("main")),
] { Some(target_snapshot.version().to_string()),
if prepared.version() != current.version() { Some(fresh_target_snapshot.version().to_string()),
return Err(OmniError::manifest_read_set_changed( ));
format!( }
"branch_merge_{member}:{}", // Revalidate the complete target authority (branch incarnation, exact
if member == "source" { // graph head, and schema identity), not only its numeric manifest
source_branch.unwrap_or("main") // version. This is the same coarse token mutation/load publish under.
} else { self.revalidate_write_txn(target_txn).await?;
target_branch.unwrap_or("main")
} // Source is an immutable input, not part of the target CAS. A later
), // source-head advance is therefore harmless, but delete/recreate ABA is
Some(prepared.version().to_string()), // not: the captured snapshot must still belong to the same native ref
Some(current.version().to_string()), // incarnation and accepted schema contract.
)); let live_source = self.open_write_txn(source_branch).await?;
if live_source.authority.branch_identifier != source_txn.authority.branch_identifier {
return Err(OmniError::manifest_read_set_changed(
format!(
"branch_merge_source_incarnation:{}",
source_branch.unwrap_or("main")
),
Some(
serde_json::to_string(&source_txn.authority.branch_identifier).map_err(
|error| {
OmniError::manifest_internal(format!(
"serialize captured source branch identifier: {error}"
))
},
)?,
),
Some(
serde_json::to_string(&live_source.authority.branch_identifier).map_err(
|error| {
OmniError::manifest_internal(format!(
"serialize live source branch identifier: {error}"
))
},
)?,
),
));
}
if live_source.authority.schema_ir_hash != source_txn.authority.schema_ir_hash
|| live_source.authority.schema_identity_version
!= source_txn.authority.schema_identity_version
{
return Err(OmniError::manifest_read_set_changed(
"branch_merge_source_schema".to_string(),
Some(format!(
"{}:{}",
source_txn.authority.schema_identity_version,
source_txn.authority.schema_ir_hash
)),
Some(format!(
"{}:{}",
live_source.authority.schema_identity_version,
live_source.authority.schema_ir_hash
)),
));
}
let expected_versions = ordered_table_keys
.iter()
.map(|table_key| {
(
table_key.clone(),
target_snapshot
.entry(table_key)
.map(|entry| entry.table_version)
.unwrap_or(0),
)
})
.collect::<HashMap<_, _>>();
let mut merge_lineage = self
.new_lineage_intent_for_branch(target_branch, actor_id)
.await?;
merge_lineage.merged_parent_commit_id = Some(source_head_commit_id.to_string());
// Build the v4 recovery envelope. Physical pins are a subset of the
// complete intended manifest delta: pointer-only candidates have no
// pre-authority effect, but must still be replayed if a sibling table's
// durable effect forces recovery to finish the merge atomically.
let mut recovery_pins = Vec::new();
let mut recovery_effects = Vec::new();
let mut delta_slots = Vec::new();
let mut first_touch_effects = HashSet::new();
let mut planned_transactions_by_table = HashMap::new();
for table_key in &ordered_table_keys {
let Some(candidate) = candidates.get(table_key) else {
continue;
};
let source_entry = source_snapshot.entry(table_key).ok_or_else(|| {
OmniError::manifest(format!(
"branch merge candidate '{}' has no captured source entry",
table_key
))
})?;
let target_entry = target_snapshot.entry(table_key);
let expected_version = target_entry.map(|entry| entry.table_version).unwrap_or(0);
let planned_output_branch = match candidate {
CandidateTableState::RewriteMerged(_) | CandidateTableState::AdoptWithDelta(_) => {
active_branch_for_keys.clone()
}
CandidateTableState::AdoptSourceState { .. } => {
match (target_branch, source_entry.table_branch.as_deref()) {
(Some(target), Some(_)) => Some(target.to_string()),
_ => None,
}
}
};
delta_slots.push(crate::db::manifest::RecoveryTableUpdateSlot {
table_key: table_key.clone(),
expected_version,
table_branch: planned_output_branch,
confirmed: None,
});
match candidate {
CandidateTableState::RewriteMerged(_) | CandidateTableState::AdoptWithDelta(_) => {
let entry = target_entry.ok_or_else(|| {
OmniError::manifest(format!(
"HEAD-advancing branch merge candidate '{}' has no target entry",
table_key
))
})?;
let source_fork_version = target_branch
.filter(|target| entry.table_branch.as_deref() != Some(*target))
.map(|_| entry.table_version);
if source_fork_version.is_some() {
first_touch_effects.insert(table_key.clone());
}
let planned_transactions =
plan_merge_transactions(table_key, candidate, expected_version)?;
planned_transactions_by_table
.insert(table_key.clone(), planned_transactions.clone());
recovery_pins.push(crate::db::manifest::SidecarTablePin {
table_key: table_key.clone(),
table_path: self.storage().dataset_uri(&entry.table_path),
expected_version,
post_commit_pin: expected_version + 1,
confirmed_version: None,
table_branch: active_branch_for_keys.clone(),
});
recovery_effects.push(crate::db::manifest::RecoveryBranchMergeEffect {
table_key: table_key.clone(),
kind: crate::db::manifest::RecoveryBranchMergeEffectKind::MultiCommitHead {
source_fork_version,
planned_transactions,
confirmed_version: None,
confirmed_branch_identifier: None,
},
});
}
CandidateTableState::AdoptSourceState { .. } => {
let Some(target) = target_branch else {
continue;
};
let creates_target_ref = source_entry.table_branch.is_some()
&& target_entry.and_then(|entry| entry.table_branch.as_deref())
!= Some(target);
if !creates_target_ref {
continue;
}
first_touch_effects.insert(table_key.clone());
recovery_pins.push(crate::db::manifest::SidecarTablePin {
table_key: table_key.clone(),
table_path: self.storage().dataset_uri(&source_entry.table_path),
expected_version,
post_commit_pin: source_entry.table_version,
confirmed_version: None,
table_branch: Some(target.to_string()),
});
recovery_effects.push(crate::db::manifest::RecoveryBranchMergeEffect {
table_key: table_key.clone(),
kind: crate::db::manifest::RecoveryBranchMergeEffectKind::RefOnlyFork {
source_version: source_entry.table_version,
confirmed_branch_identifier: None,
},
});
}
} }
} }
let recovery_pins: Vec<crate::db::manifest::SidecarTablePin> = ordered_table_keys // Keep the sidecar alongside its handle: after the whole physical
.iter() // effect set completes, confirmation binds every output slot and every
.filter_map(|table_key| { // first-touch ref identity before the one manifest CAS.
let candidate = candidates.get(table_key)?;
if !matches!(
candidate,
CandidateTableState::RewriteMerged(_) | CandidateTableState::AdoptWithDelta(_)
) {
return None;
}
let entry = target_snapshot.entry(table_key)?;
Some(crate::db::manifest::SidecarTablePin {
table_key: table_key.clone(),
table_path: self.storage().dataset_uri(&entry.table_path),
expected_version: entry.table_version,
post_commit_pin: entry.table_version + 1,
// Stamped after the whole per-table publish completes
// (Phase-B confirmation, just before the manifest publish).
// Until then `None` marks an unfinished publish that
// recovery must roll back, not roll forward.
confirmed_version: None,
// Use the merge target branch (where commits actually
// land), NOT entry.table_branch (where the table
// currently lives). publish_rewritten_merge_table calls
// open_for_mutation, which forks an inherited-from-main
// table to active_branch on first write — the resulting
// Lance commit lands on active_branch. Recovery's
// open_lance_head must check the same branch, otherwise
// an inherited-table feature-to-feature merge classifies
// as NoMovement and the all-or-nothing rollback skips
// the orphaned post-Phase-B HEAD on the target ref.
// Same rationale as table_ops.rs:115-120 in
// ensure_indices_for_branch.
table_branch: active_branch_for_keys.clone(),
})
})
.collect();
// Keep the sidecar alongside its handle: after the per-table publish
// loop completes (Phase B), we re-write it with each table's confirmed
// version before the manifest publish, so recovery can tell a finished
// publish (roll forward) from a partial one (roll back).
let mut recovery: Option<( let mut recovery: Option<(
crate::db::manifest::RecoverySidecar, crate::db::manifest::RecoverySidecar,
crate::db::manifest::RecoverySidecarHandle, crate::db::manifest::RecoverySidecarHandle,
)> = if recovery_pins.is_empty() { )> = if recovery_pins.is_empty() {
None None
} else { } else {
// Use the merge target branch directly, NOT a heuristic let authority = crate::db::manifest::RecoveryAuthorityToken {
// derived from `ordered_table_keys.first()`. The first branch_identifier: target_txn.authority.branch_identifier.clone(),
// sorted table key may not be in the target snapshot at all graph_head: target_txn.authority.graph_head.clone(),
// (its `entry()` returns None → branch becomes None == main), schema_ir_hash: target_txn.authority.schema_ir_hash.clone(),
// and the SubTableEntry's `table_branch` field isn't schema_identity_version: target_txn.authority.schema_identity_version,
// necessarily the merge target branch. The caller };
// `branch_merge` calls `swap_coordinator_for_branch(target_branch)` let recovery_lineage = crate::db::manifest::RecoveryLineageIntent {
// before invoking this function, so `self.active_branch()` graph_commit_id: merge_lineage.graph_commit_id.clone(),
// is the target. branch: merge_lineage.branch.clone(),
let target_branch = active_branch_for_keys.clone(); actor_id: merge_lineage.actor_id.clone(),
let mut sidecar = crate::db::manifest::new_sidecar( merged_parent_commit_id: merge_lineage.merged_parent_commit_id.clone(),
crate::db::manifest::SidecarKind::BranchMerge, created_at: merge_lineage.created_at,
target_branch, };
let sidecar = crate::db::manifest::new_branch_merge_sidecar(
active_branch_for_keys.clone(),
actor_id.map(str::to_string), actor_id.map(str::to_string),
recovery_pins, recovery_pins,
); authority,
// Carry the source branch's HEAD commit id so the recovery recovery_lineage,
// sweep's audit step can record this as a MERGE commit recovery_effects,
// (linked to the source) instead of a plain commit. Without crate::db::manifest::RecoveryManifestDelta {
// this, future merges between the same pair lose table_updates: delta_slots,
// already-up-to-date detection and merge-base correctness. registrations: Vec::new(),
sidecar.merge_source_commit_id = Some(source_head_commit_id.to_string()); tombstones: Vec::new(),
},
)?;
let handle = crate::db::manifest::write_sidecar( let handle = crate::db::manifest::write_sidecar(
self.root_uri(), self.root_uri(),
self.storage_adapter(), self.storage_adapter(),
@ -1629,72 +1857,134 @@ impl Omnigraph {
Some((sidecar, handle)) Some((sidecar, handle))
}; };
let mut updates = Vec::new(); let recovery_operation_id = recovery
let mut changed_edge_tables = false; .as_ref()
for table_key in &ordered_table_keys { .map(|(_, handle)| handle.operation_id.clone());
let Some(candidate_state) = candidates.get(table_key) else { let post_arm_result = async {
continue; if recovery.is_some() && !first_touch_effects.is_empty() {
}; crate::failpoints::maybe_fail(
let update = match candidate_state { crate::failpoints::names::BRANCH_MERGE_POST_SIDECAR_PRE_FORK,
CandidateTableState::AdoptSourceState { .. } => { )?;
publish_adopted_source_state(self, source_snapshot, target_snapshot, table_key)
.await?
}
CandidateTableState::AdoptWithDelta(delta) => {
publish_adopted_delta(self, table_key, delta).await?
}
CandidateTableState::RewriteMerged(staged) => {
publish_rewritten_merge_table(self, table_key, staged).await?
}
};
if table_key.starts_with("edge:") {
changed_edge_tables = true;
} }
updates.push(update);
}
// Phase-B confirmation: every table's publish finished, so stamp the let mut updates = Vec::new();
// sidecar with each table's exact achieved version before the manifest let mut changed_edge_tables = false;
// publish. This is the commit point of the recovery WAL: a crash from let mut confirmed_ref_identifiers = HashMap::new();
// here on rolls FORWARD to these versions, while a crash anywhere in the for table_key in &ordered_table_keys {
// publish loop above left the sidecar unconfirmed and rolls BACK. The let Some(candidate_state) = candidates.get(table_key) else {
// `updates` carry the real per-table final versions (multiple continue;
// commit_staged calls per table, so not derivable from `post_commit_pin` };
// alone). A failure here leaves the unconfirmed sidecar → roll back. let update = match candidate_state {
crate::failpoints::maybe_fail( CandidateTableState::AdoptSourceState { .. } => {
crate::failpoints::names::BRANCH_MERGE_POST_EFFECTS_PRE_CONFIRM, publish_adopted_source_state(
)?; self,
if let Some((sidecar, _)) = recovery.as_mut() { source_snapshot,
let confirmed_versions: std::collections::HashMap<String, u64> = updates target_snapshot,
.iter() table_key,
.map(|u| (u.table_key.clone(), u.table_version)) )
.collect(); .await?
crate::db::manifest::confirm_sidecar_phase_b( }
self.root_uri(), CandidateTableState::AdoptWithDelta(delta) => {
self.storage_adapter(), let planned = planned_transactions_by_table.get(table_key).ok_or_else(|| {
sidecar, OmniError::manifest_internal(format!(
&confirmed_versions, "branch merge table '{table_key}' lacks its armed transaction chain"
))
})?;
publish_adopted_delta(self, table_key, delta, planned).await?
}
CandidateTableState::RewriteMerged(staged) => {
let planned = planned_transactions_by_table.get(table_key).ok_or_else(|| {
OmniError::manifest_internal(format!(
"branch merge table '{table_key}' lacks its armed transaction chain"
))
})?;
publish_rewritten_merge_table(self, table_key, staged, planned).await?
}
};
if first_touch_effects.contains(table_key) {
let target = target_branch.ok_or_else(|| {
OmniError::manifest_internal(format!(
"first-touch merge effect '{}' has no named target branch",
table_key
))
})?;
let entry = target_snapshot
.entry(table_key)
.or_else(|| source_snapshot.entry(table_key))
.ok_or_else(|| {
OmniError::manifest_internal(format!(
"first-touch merge effect '{}' has no table path",
table_key
))
})?;
let full_path = self.storage().dataset_uri(&entry.table_path);
let target_head = self
.storage()
.open_dataset_head(&full_path, Some(target))
.await?;
confirmed_ref_identifiers.insert(
table_key.clone(),
self.storage().branch_identifier(&target_head).await?,
);
}
if table_key.starts_with("edge:") {
changed_edge_tables = true;
}
updates.push(update);
}
// The Armed body remains rollback-only until every physical effect,
// every first-touch ref identity, and every logical output slot are
// durably confirmed together.
crate::failpoints::maybe_fail(
crate::failpoints::names::BRANCH_MERGE_POST_EFFECTS_PRE_CONFIRM,
)?;
if let Some((sidecar, _)) = recovery.as_mut() {
crate::db::manifest::confirm_branch_merge_sidecar_phase_b(
self.root_uri(),
self.storage_adapter(),
sidecar,
&updates,
&confirmed_ref_identifiers,
)
.await?;
}
crate::failpoints::maybe_fail(
crate::failpoints::names::BRANCH_MERGE_POST_PHASE_B_PRE_MANIFEST_COMMIT,
)?;
// Publish the complete merge delta and its pre-minted lineage under
// the exact target authority captured before classification.
debug_assert_eq!(
target_txn.effective_graph_head.as_deref(),
Some(target_head_commit_id)
);
self.commit_updates_on_branch_with_expected(
target_branch,
&updates,
&expected_versions,
actor_id,
target_txn,
merge_lineage,
) )
.await?; .await?;
Ok::<_, OmniError>((updates, changed_edge_tables))
} }
.await;
// Failpoint: pin the per-writer Phase B → Phase C residual for let (_updates, changed_edge_tables) = match post_arm_result {
// branch_merge. Lance HEAD has advanced on every touched table Ok(result) => result,
// (publish_*) AND the sidecar is confirmed, but the manifest publish Err(error) => {
// below hasn't run — so recovery rolls FORWARD. Used by return match recovery_operation_id {
// `tests/failpoints.rs::branch_merge_phase_b_failure_recovered_on_next_open`. Some(operation_id) => Err(OmniError::recovery_required(
crate::failpoints::maybe_fail(crate::failpoints::names::BRANCH_MERGE_POST_PHASE_B_PRE_MANIFEST_COMMIT)?; operation_id,
error.to_string(),
// Publish the merged table versions AND the merge commit in one manifest )),
// CAS (RFC-013 Phase 7): `graph_commit` + `graph_head` rows ride the same None => Err(error),
// merge-insert as the table-version rows. The merge commit's first parent };
// is resolved by the publisher as the live target-branch head (the }
// post-merge correct parent even if the target advanced); its merged-in };
// parent is the source head. `target_head_commit_id` is no longer passed
// — it was the pre-merge target head, which the publisher reads live.
let _ = target_head_commit_id;
self.commit_merge_with_actor(&updates, source_head_commit_id, actor_id)
.await?;
// Recovery sidecar lifecycle: delete after the manifest publish (Phase C). // Recovery sidecar lifecycle: delete after the manifest publish (Phase C).
// Best-effort cleanup; the merge already landed durably so failing the // Best-effort cleanup; the merge already landed durably so failing the

View file

@ -35,7 +35,7 @@ use time::format_description::well_known::Rfc3339;
use crate::db::commit_graph::CommitGraph; use crate::db::commit_graph::CommitGraph;
use crate::db::manifest::ManifestCoordinator; use crate::db::manifest::ManifestCoordinator;
use crate::db::{MergeOutcome, Omnigraph, is_internal_system_branch}; use crate::db::{MergeOutcome, Omnigraph, WriteTxn, is_internal_system_branch};
use crate::db::{ReadTarget, Snapshot}; use crate::db::{ReadTarget, Snapshot};
use crate::embedding::EmbeddingClient; use crate::embedding::EmbeddingClient;
use crate::error::{MergeConflict, MergeConflictKind, OmniError, Result}; use crate::error::{MergeConflict, MergeConflictKind, OmniError, Result};

View file

@ -64,6 +64,10 @@ pub mod names {
/// any durable table effect. /// any durable table effect.
pub const BRANCH_MERGE_POST_AUTHORITY_CAPTURE: &str = pub const BRANCH_MERGE_POST_AUTHORITY_CAPTURE: &str =
"branch_merge.post_authority_capture"; "branch_merge.post_authority_capture";
/// The v4 BranchMerge recovery intent is durable, before any first-touch
/// target table ref is created.
pub const BRANCH_MERGE_POST_SIDECAR_PRE_FORK: &str =
"branch_merge.post_sidecar_pre_fork";
pub const BRANCH_MERGE_POST_PHASE_B_PRE_MANIFEST_COMMIT: &str = pub const BRANCH_MERGE_POST_PHASE_B_PRE_MANIFEST_COMMIT: &str =
"branch_merge.post_phase_b_pre_manifest_commit"; "branch_merge.post_phase_b_pre_manifest_commit";
/// Every merge table effect is complete, but the sidecar is still in its /// Every merge table effect is complete, but the sidecar is still in its
@ -133,6 +137,11 @@ pub mod names {
/// its operator-facing audit row is appended. /// its operator-facing audit row is appended.
pub const RECOVERY_POST_ROLLBACK_PUBLISH_PRE_AUDIT: &str = pub const RECOVERY_POST_ROLLBACK_PUBLISH_PRE_AUDIT: &str =
"recovery.post_rollback_publish_pre_audit"; "recovery.post_rollback_publish_pre_audit";
/// After recovery restores one table to its prepared pre-effect content,
/// before the compensating manifest publish. A retry must recognize that
/// restore as this sidecar's owned compensation instead of wedging open.
pub const RECOVERY_POST_TABLE_RESTORE_PRE_PUBLISH: &str =
"recovery.post_table_restore_pre_publish";
pub const RECOVERY_RECORD_AUDIT: &str = "recovery.record_audit"; pub const RECOVERY_RECORD_AUDIT: &str = "recovery.record_audit";
pub const RECOVERY_SIDECAR_CONFIRM: &str = "recovery.sidecar_confirm"; pub const RECOVERY_SIDECAR_CONFIRM: &str = "recovery.sidecar_confirm";
pub const RECOVERY_SIDECAR_DELETE: &str = "recovery.sidecar_delete"; pub const RECOVERY_SIDECAR_DELETE: &str = "recovery.sidecar_delete";

View file

@ -65,7 +65,7 @@ use lance::dataset::scanner::{ColumnOrdering, DatasetRecordBatchStream};
use lance::dataset::{WhenMatched, WhenNotMatched}; use lance::dataset::{WhenMatched, WhenNotMatched};
use crate::db::{Snapshot, SubTableEntry}; use crate::db::{Snapshot, SubTableEntry};
use crate::error::Result; use crate::error::{OmniError, Result};
use crate::table_store::{StagedTransactionIdentity, StagedWrite, TableState, TableStore}; use crate::table_store::{StagedTransactionIdentity, StagedWrite, TableState, TableStore};
// ─── sealed module ────────────────────────────────────────────────────────── // ─── sealed module ──────────────────────────────────────────────────────────
@ -286,6 +286,14 @@ pub trait TableStorage: sealed::Sealed + Send + Sync + Debug {
branch: Option<&str>, branch: Option<&str>,
) -> Result<SnapshotHandle>; ) -> Result<SnapshotHandle>;
/// Native identity of the branch backing an already-open snapshot. Used
/// by recovery-enrolled first-touch effects to confirm the exact ref they
/// created, closing delete/recreate ABA during later recovery.
async fn branch_identifier(
&self,
snapshot: &SnapshotHandle,
) -> Result<lance::dataset::refs::BranchIdentifier>;
async fn fork_branch_from_state( async fn fork_branch_from_state(
&self, &self,
dataset_uri: &str, dataset_uri: &str,
@ -564,6 +572,17 @@ impl TableStorage for TableStore {
.map(SnapshotHandle::new) .map(SnapshotHandle::new)
} }
async fn branch_identifier(
&self,
snapshot: &SnapshotHandle,
) -> Result<lance::dataset::refs::BranchIdentifier> {
snapshot
.dataset()
.branch_identifier()
.await
.map_err(|error| OmniError::Lance(error.to_string()))
}
async fn fork_branch_from_state( async fn fork_branch_from_state(
&self, &self,
dataset_uri: &str, dataset_uri: &str,

File diff suppressed because it is too large Load diff

View file

@ -16,8 +16,9 @@ pub enum RecoveryExpectation {
RolledForward { RolledForward {
tables: Vec<TableExpectation>, tables: Vec<TableExpectation>,
}, },
/// Protocol-v3 mutation/load recovery republishes the writer's fixed /// RFC-022 v3 mutation/load and v4 branch-merge recovery republish the
/// lineage intent rather than minting a synthetic recovery commit. /// writer's fixed lineage intent rather than minting a synthetic recovery
/// commit.
RolledForwardOriginalLineage { RolledForwardOriginalLineage {
tables: Vec<TableExpectation>, tables: Vec<TableExpectation>,
}, },

View file

@ -53,7 +53,9 @@ where
/// one inserted Person must NOT open the untouched tables for validation — cost /// one inserted Person must NOT open the untouched tables for validation — cost
/// follows the delta, not the catalog. Pre-#5 this opened ~6 tables via a /// follows the delta, not the catalog. Pre-#5 this opened ~6 tables via a
/// full-graph validation scan; the index-backed evaluator probes only the /// full-graph validation scan; the index-backed evaluator probes only the
/// committed Person table (for uniqueness) plus the delta. /// committed Person table (for uniqueness) plus the delta, and RFC-022 v4
/// re-opens the one physical effect to prove its exact transaction history
/// before confirmation.
#[test] #[test]
fn merge_validation_is_delta_scoped() { fn merge_validation_is_delta_scoped() {
on_big_stack(|| async { on_big_stack(|| async {
@ -102,12 +104,12 @@ fn merge_validation_is_delta_scoped() {
); );
// The proof: only Person changed, so the merge opens only Person-related // The proof: only Person changed, so the merge opens only Person-related
// tables (the delta + the committed-target index probe for uniqueness) — // tables (the delta + committed-target index probe + exact confirmation
// never the untouched Company / Knows / WorksAt. Pre-#5 this was ~6 // re-observation) — never the untouched Company / Knows / WorksAt.
// (every catalog table, full-scanned). // Pre-#5 this was ~6 (every catalog table, full-scanned).
assert!( assert!(
io.data_open_count <= 3, io.data_open_count <= 4,
"merge of a 1-Person delta opened {} data tables; expected <= 3 (Δ-scoped). \ "merge of a 1-Person delta opened {} data tables; expected <= 4 (Δ-scoped, including exact confirmation). \
Pre-#5 it opened every catalog table (~6) via a whole-graph validation scan.", Pre-#5 it opened every catalog table (~6) via a whole-graph validation scan.",
io.data_open_count io.data_open_count
); );

View file

@ -258,10 +258,15 @@ them explicit.
schema-apply lock branch) — design it before promoting multi-process write schema-apply lock branch) — design it before promoting multi-process write
topologies. topologies.
- **Fork ownership is durable, but Lance ref deletion is not conditional:** a - **Fork ownership is durable, but Lance ref deletion is not conditional:** a
first-touch Mutation/Load table no longer creates its target ref while first-touch Mutation/Load or BranchMerge table never creates its target ref
preparing. Under schema → branch → table gates it revalidates, durably arms a before recovery ownership is durable. Under schema → branch → table gates it
schema-v3 sidecar naming the target, creates the ref, then stages branch-local revalidates and arms a schema-v3 mutation/load or schema-v4 merge sidecar
files and commits. Both `reclaim_orphaned_fork_and_refork` and naming the target; branch merge additionally records the exact fork version
and a pre-minted ordered transaction chain for every logical data effect,
commits that chain without transparent conflict retries, and confirms the
minted target `BranchIdentifier`. Recovery proves the exact chain rather than
treating numeric HEAD movement as ownership. Both
`reclaim_orphaned_fork_and_refork` and
`reconcile_orphaned_branches` consult pending sidecars before destruction; a `reconcile_orphaned_branches` consult pending sidecars before destruction; a
foreign claim is conflict/indeterminate, never permission to delete. Full foreign claim is conflict/indeterminate, never permission to delete. Full
recovery accepts sidecar-before-ref crashes. When `BranchContents` is absent, recovery accepts sidecar-before-ref crashes. When `BranchContents` is absent,

View file

@ -36,7 +36,7 @@ The implementation spec's hard gate for this phase — failpoint recovery tests
What Phase 4 builds on (all shipped): What Phase 4 builds on (all shipped):
- **The engine's recovery discipline.** Writers that can advance Lance HEAD before manifest publish write `__recovery/{ulid}.json` sidecars carrying per-table pins (`expected_version`, `post_commit_pin`); `Omnigraph::open` in read-write mode classifies every pinned table (`NoMovement` / `RolledPastExpected` / `UnexpectedAtP1` / `UnexpectedMultistep` / `InvariantViolation`) and decides all-or-nothing: roll forward via one manifest publish, or roll back via `Dataset::restore`. Every completed action records an audit row with the interrupted writer's actor in `recovery_for_actor`. A schema-v3 roll-forward publishes the sidecar's pre-minted lineage and therefore preserves that writer's commit id and actor; rollback and legacy recovery commits use `omnigraph:recovery`. The cluster inherits the *vocabulary* of this design but not its mechanics — see the roll-forward-only argument below. - **The engine's recovery discipline.** Writers that can advance Lance HEAD before manifest publish write `__recovery/{ulid}.json` sidecars carrying per-table pins (`expected_version`, `post_commit_pin`); `Omnigraph::open` in read-write mode classifies every pinned table (`NoMovement` / `RolledPastExpected` / `UnexpectedAtP1` / `UnexpectedMultistep` / `InvariantViolation`) and decides all-or-nothing: roll forward via one manifest publish, or roll back via `Dataset::restore`. Every completed action records an audit row with the interrupted writer's actor in `recovery_for_actor`. Schema-v3 Mutation/Load and schema-v4 BranchMerge roll-forward publish the sidecar's pre-minted lineage and therefore preserve that writer's commit id and actor; v4 additionally proves each multi-commit data effect from its ordered pre-minted Lance transaction chain. Rollback and legacy recovery commits use `omnigraph:recovery`. The cluster inherits the *vocabulary* of this design but not its mechanics — see the roll-forward-only argument below.
- **The engine's schema-apply surface.** `apply_schema_as(desired_source, SchemaApplyOptions { allow_data_loss }, actor)` returns `SchemaApplyResult { supported, applied, manifest_version, steps }`; `preview_schema_apply_with_options` returns the migration plan plus desired catalog without applying; the `__schema_apply_lock__` branch serializes schema applies graph-wide and refuses to run while user branches exist. Policy enforcement (`enforce(SchemaApply, TargetBranch("main"), actor)`) happens before the lock. - **The engine's schema-apply surface.** `apply_schema_as(desired_source, SchemaApplyOptions { allow_data_loss }, actor)` returns `SchemaApplyResult { supported, applied, manifest_version, steps }`; `preview_schema_apply_with_options` returns the migration plan plus desired catalog without applying; the `__schema_apply_lock__` branch serializes schema applies graph-wide and refuses to run while user branches exist. Policy enforcement (`enforce(SchemaApply, TargetBranch("main"), actor)`) happens before the lock.
- **Graph init.** `Omnigraph::init(uri, schema_source)` with a strict preflight (errors if schema artifacts exist) and an atomic `_schema.pg` claim. A documented gap: a failed init does not clean up Lance datasets or `__manifest/` it already created. - **Graph init.** `Omnigraph::init(uri, schema_source)` with a strict preflight (errors if schema artifacts exist) and an atomic `_schema.pg` claim. A documented gap: a failed init does not clean up Lance datasets or `__manifest/` it already created.
- **No engine graph-delete primitive.** Deleting a graph today means removing its object-store prefix. This RFC works with that fact rather than waiting on a primitive. - **No engine graph-delete primitive.** Deleting a graph today means removing its object-store prefix. This RFC works with that fact rather than waiting on a primitive.

View file

@ -432,8 +432,9 @@ retaining the public lifecycle status `Proposed`.
**Affected:** [RFC-022 §6.2](../rfcs/rfc-022-unified-write-path.md#62-branch-merge) **Affected:** [RFC-022 §6.2](../rfcs/rfc-022-unified-write-path.md#62-branch-merge)
**Status:** Closed in specification on 2026-07-11; full merge-adapter conversion **Status:** Closed in specification and implementation on 2026-07-11. Branch
remains rollout work rather than an architecture ambiguity. merge captures an immutable source commit and revalidates only its incarnation;
the target publishes and recovers under its own exact authority token.
RFC-022 requires every `ReadSet` member to be arbitrated atomically by the RFC-022 requires every `ReadSet` member to be arbitrated atomically by the
publish CAS. A CAS on reserved main cannot arbitrate a row on a named source publish CAS. A CAS on reserved main cannot arbitrate a row on a named source
@ -516,6 +517,14 @@ performance.
> multi-process native-ref fencing. RFC-024 remains the false-contention > multi-process native-ref fencing. RFC-024 remains the false-contention
> narrowing step. > narrowing step.
> **Branch-merge implementation disposition (2026-07-11):** branch merge uses
> the same coarse target token without pretending the source belongs to that
> atomic read set. Schema-v4 recovery persists the captured source parent,
> fixed merge/rollback ids, pre-minted exact transaction chains for multi-commit
> data effects, ref-only physical effects, and the complete logical delta. A later source advance remains harmless; a target
> advance after effects becomes `RecoveryRequired` and is compensated rather
> than re-parented.
### TIGHTENING-01 — symmetric Lance conflicts are not obviously an activation gate ### TIGHTENING-01 — symmetric Lance conflicts are not obviously an activation gate
RFC-023 correctly identifies Lance's directional filtered/unfiltered behavior. RFC-023 correctly identifies Lance's directional filtered/unfiltered behavior.

View file

@ -52,9 +52,9 @@ The engine's `tests/` is the principal coverage surface; most graph-shaped behav
| `merge_cost.rs` | Cost-budget tests for branch MERGE on the shared `helpers::cost` harness: `merge_validation_is_delta_scoped` (a 1-row-delta merge opens ≤3 data tables — Δ-scoped, not the whole catalog; was ~6 pre-#5) and `merge_manifest_cost_grows_with_history` (the cross-branch `__manifest` open amplification still grows with commit depth — a separate, not-yet-addressed term — while validation `data_open_count` stays flat) | | `merge_cost.rs` | Cost-budget tests for branch MERGE on the shared `helpers::cost` harness: `merge_validation_is_delta_scoped` (a 1-row-delta merge opens ≤3 data tables — Δ-scoped, not the whole catalog; was ~6 pre-#5) and `merge_manifest_cost_grows_with_history` (the cross-branch `__manifest` open amplification still grows with commit depth — a separate, not-yet-addressed term — while validation `data_open_count` stays flat) |
| `policy_engine_chassis.rs` | Engine-layer Cedar enforcement (MR-722): allow + deny through every `_as` writer via the SDK directly — no HTTP — proving embedded and CLI callers hit the same gate as the server, with action × scope shapes matching `authorize_request` | | `policy_engine_chassis.rs` | Engine-layer Cedar enforcement (MR-722): allow + deny through every `_as` writer via the SDK directly — no HTTP — proving embedded and CLI callers hit the same gate as the server, with action × scope shapes matching `authorize_request` |
| `maintenance.rs` | `optimize` (compaction), `repair` (explicit uncovered-drift publish), and `cleanup` (version GC): empty/idempotent/no-op edges, policy validation, head preservation; cleanup pins exact keep-count behavior (including keep larger than history), count/time retention of a live lazy branch, the oldest of multiple lazy pins, graph-wide fail-closed ordering on an unopenable pin, and refusal of uncovered main HEAD drift before any GC; `optimize` publishes its own compaction (`optimize_publishes_compaction_to_manifest_so_schema_apply_succeeds`), skips pre-existing uncovered drift (`optimize_skips_preexisting_manifest_head_drift`), refuses to run while a `__recovery` sidecar is pending (`optimize_defers_when_recovery_sidecar_is_pending`), and compacts blob-v2 tables through the normal path (`optimize_compacts_blob_table_alongside_plain_table`); `repair` previews/heals verified maintenance drift, refuses raw semantic drift without `--force`, and forced repair publishes only by explicit operator choice; the index reconciler (iss-848): `index_build_tolerates_null_vector_rows` (logical load succeeds without inline index work; an untrainable Vector column then defers during reconciliation) and `optimize_materializes_index_declared_but_unbuilt` (optimize creates a declared-but-deferred index) | | `maintenance.rs` | `optimize` (compaction), `repair` (explicit uncovered-drift publish), and `cleanup` (version GC): empty/idempotent/no-op edges, policy validation, head preservation; cleanup pins exact keep-count behavior (including keep larger than history), count/time retention of a live lazy branch, the oldest of multiple lazy pins, graph-wide fail-closed ordering on an unopenable pin, and refusal of uncovered main HEAD drift before any GC; `optimize` publishes its own compaction (`optimize_publishes_compaction_to_manifest_so_schema_apply_succeeds`), skips pre-existing uncovered drift (`optimize_skips_preexisting_manifest_head_drift`), refuses to run while a `__recovery` sidecar is pending (`optimize_defers_when_recovery_sidecar_is_pending`), and compacts blob-v2 tables through the normal path (`optimize_compacts_blob_table_alongside_plain_table`); `repair` previews/heals verified maintenance drift, refuses raw semantic drift without `--force`, and forced repair publishes only by explicit operator choice; the index reconciler (iss-848): `index_build_tolerates_null_vector_rows` (logical load succeeds without inline index work; an untrainable Vector column then defers during reconciliation) and `optimize_materializes_index_declared_but_unbuilt` (optimize creates a declared-but-deferred index) |
| `failpoints.rs` | Failure-injection coverage (gated on `failpoints` feature). RFC-022 includes deterministic post-stage/pre-effect races for mutation/load uniqueness and strict disjoint-head changes, plus the cross-handle post-effect `RecoveryRequired` → read-write-open rollback cell. Native controls are pinned by `native_branch_controls_reclassify_lost_acknowledgements` (matching create and absent-ref delete, with no version/lineage movement); `armed_first_touch_recovery_accepts_missing_target_ref` additionally forges and reclaims the clone-only/no-`BranchContents` table state. Legacy path overlap has both sides pinned: `armed_first_touch_recovery_defers_legacy_path_overlap_until_leaf_delete` permits open only for a proven no-effect intent, while `partial_first_touch_recovery_fails_closed_on_legacy_path_overlap` leaves one exact multi-table effect and verifies open fails closed until offline leaf cleanup lets rollback converge. Other control/recovery race cells include `first_touch_post_create_open_error_keeps_recovery_ownership`, `branch_delete_orphans_sidecar_armed_after_initial_barrier`, `branch_merge_fences_target_delete_recreate_aba`, `branch_merge_fences_concurrent_sync_on_same_handle`, `branch_merge_rejects_fresh_target_manifest_change_before_effects`, `branch_merge_rechecks_late_sidecar_after_table_gates`, `cleanup_rechecks_sidecars_under_gc_gates`, and `full_recovery_rereads_sidecar_body_after_discovery`. The suite also includes the five per-writer effect → manifest-CAS recovery tests, write-entry in-process heal contract, storage-fault matrix, S3 recovery twin, and convergence-idempotent roll-forward regression. | | `failpoints.rs` | Failure-injection coverage (gated on `failpoints` feature). RFC-022 includes deterministic post-stage/pre-effect races for mutation/load uniqueness and strict disjoint-head changes, plus the cross-handle post-effect `RecoveryRequired` → read-write-open rollback cell. Branch merge adds the captured-source advance cell; post-confirm target-winner compensation; mixed physical + pointer-only delta recovery with fixed commit id/actor/parents; and both sidecar-before-first-ref and ambiguous-ref-create recovery. Native controls are pinned by `native_branch_controls_reclassify_lost_acknowledgements` (matching create and absent-ref delete, with no version/lineage movement); `armed_first_touch_recovery_accepts_missing_target_ref` additionally forges and reclaims the clone-only/no-`BranchContents` table state. Legacy path overlap has both sides pinned: `armed_first_touch_recovery_defers_legacy_path_overlap_until_leaf_delete` permits open only for a proven no-effect intent, while `partial_first_touch_recovery_fails_closed_on_legacy_path_overlap` leaves one exact multi-table effect and verifies open fails closed until offline leaf cleanup lets rollback converge. Other control/recovery race cells include `first_touch_post_create_open_error_keeps_recovery_ownership`, `branch_delete_orphans_sidecar_armed_after_initial_barrier`, `branch_merge_fences_target_delete_recreate_aba`, `branch_merge_fences_concurrent_sync_on_same_handle`, `branch_merge_rejects_fresh_target_manifest_change_before_effects`, `branch_merge_rechecks_late_sidecar_after_table_gates`, `cleanup_rechecks_sidecars_under_gc_gates`, and `full_recovery_rereads_sidecar_body_after_discovery`. The suite also includes the five per-writer effect → manifest-CAS recovery tests, write-entry in-process heal contract, storage-fault matrix, S3 recovery twin, and convergence-idempotent roll-forward regression. |
| `failpoint_names_guard.rs` | Source-walk guard: every `maybe_fail(...)` call site (engine + cluster) must reference the compile-checked `failpoints::names` consts, never a bare string literal — a typo'd literal compiles but silently never fires; same defense-in-depth shape as `forbidden_apis.rs` | | `failpoint_names_guard.rs` | Source-walk guard: every `maybe_fail(...)` call site (engine + cluster) must reference the compile-checked `failpoints::names` consts, never a bare string literal — a typo'd literal compiles but silently never fires; same defense-in-depth shape as `forbidden_apis.rs` |
| `recovery.rs` | Open-time recovery sweep — legacy sidecars plus schema-v3 Mutation/Load exact transaction identity, fixed logical/rollback outcome IDs, branch-token comparison, fresh under-gate sidecar reread/reparse, all-or-nothing roll-forward/rollback/refusal, audit row in `_graph_commit_recoveries.lance`, and `OpenMode::ReadOnly` skip path | | `recovery.rs` | Open-time recovery sweep — legacy sidecars; schema-v3 Mutation/Load exact transaction identity; schema-v4 BranchMerge exact multi-commit chains/ref-only effects plus complete logical delta; restartable compensation recognition; fixed logical/rollback outcome IDs; branch-token comparison; fresh under-gate sidecar reread/reparse; all-or-nothing roll-forward/rollback/refusal; audit row in `_graph_commit_recoveries.lance`; and `OpenMode::ReadOnly` skip path |
| `composite_flow.rs` | Compositional/narrative end-to-end stories — multi-step flows that compose mechanics covered by other test files. Catches integration regressions where individual operations all pass their unit tests but their composition breaks (sequential merges, post-merge main writes, time-travel through merge DAG, reopen consistency over multi-merge histories, post-optimize and post-cleanup strict writes). | | `composite_flow.rs` | Compositional/narrative end-to-end stories — multi-step flows that compose mechanics covered by other test files. Catches integration regressions where individual operations all pass their unit tests but their composition breaks (sequential merges, post-merge main writes, time-travel through merge DAG, reopen consistency over multi-merge histories, post-optimize and post-cleanup strict writes). |
## Fixtures ## Fixtures

View file

@ -75,38 +75,55 @@ The native branch identifier detects delete/recreate ABA but is not a Lance
conditional-ref fence, and destructive recovery remains unsafe beside a live conditional-ref fence, and destructive recovery remains unsafe beside a live
foreign process. foreign process.
### Branch-merge authority fence (adapter bridge) ### Branch-merge authority and recovery adapter (RFC-022 v4)
Branch merge still uses its writer-specific multi-commit table effects and Branch merge retains its writer-specific row classifier and multi-commit table
confirmation sidecar; it has not yet been converted to the RFC-022 exact-effect algorithms, but its authority, recovery, and visibility boundary now use the
adapter. It does, however, join the closed control boundary needed by this first RFC-022 adapter contract:
slice: after the strict recovery barrier it acquires the root-shared schema gate
and the sorted source/target branch gates, performs the final sidecar check,
loads one operation-local catalog from the accepted contract, captures both graph
heads plus the base/source/target snapshots, and holds those gates through table
effects and manifest publication. Planning stays outside table queues. Before
Phase A, merge acquires the conservative all-catalog table envelope for both
source and target, re-lists sidecars, and compares fresh source/target manifest
versions with the captured snapshots. A stale warm handle catalog or coordinator
snapshot is never accepted as that revalidation.
The source snapshot is a captured merge input, not authority that the target 1. capture source and target as coherent `WriteTxn` snapshots. The target token
manifest CAS can arbitrate. The current process-local source gate is a stronger is `(BranchIdentifier, exact optional graph_head, accepted schema identity)`;
same-process fence around that capture, including delete/recreate ABA, but the the effective lineage head is captured separately because a fresh named
semantic contract is still "merge the captured source commit." A later source branch can inherit a parent while its own `graph_head:<branch>` row is absent;
advance does not invalidate an otherwise prepared target publish. Claiming 2. compute the merge base from those captured commit ids and classify against
"latest source at target publish" would instead require a cross-process source the immutable base/source/target snapshots outside table gates;
fence held through the target CAS. 3. acquire the conservative all-catalog source/target table envelope, re-list
recovery intent, revalidate the complete target token, and revalidate the
source incarnation. A target change returns typed `ReadSetChanged` before
effects. A later source-head advance is allowed: the contract is "merge the
captured source commit," never "substitute whatever source is latest";
4. pre-mint the merge lineage and each table's ordered Lance data-transaction
chain, then arm a schema-v4 BranchMerge sidecar before the first HEAD advance
or first-touch table ref. Logical data steps commit with those exact
`(read_version, uuid)` identities and zero transparent conflict retries. Its
physical-effect set can be smaller than its intended manifest delta:
pointer-only table updates are still recorded so recovery publishes the
complete logical merge;
5. after every multi-commit table effect completes, confirm exact final table
versions, every logical `SubTableUpdate`, and every first-touch target
`BranchIdentifier`; then publish once with `ExactGraphHead` and the captured
table expectations.
That fence prevents a same-process target delete/recreate from reusing the branch Publisher retries cannot re-parent the prepared merge onto a newer target. Any
name underneath a merge plan. The race test deliberately recreates a target with failure after the v4 sidecar is durable returns `RecoveryRequired`. Full recovery
the same name and numeric Lance version but a different `BranchIdentifier`, so rolls confirmed effects forward only while the captured target authority still
version-only checking cannot accidentally satisfy it. This is a process-local matches; otherwise it compensates the owned effects while preserving the target
bridge, not a cross-process conditional-ref primitive and not a substitute for winner, or fails closed when foreign/interleaved table state makes compensation
the later full branch-merge read-set/reprepare adapter. `sync_branch` joins the unverifiable. An Armed first-touch ref with no data HEAD movement is reclaimed
same root schema gate before replacing a handle's coordinator, so it cannot without manufacturing rollback lineage. Armed recovery accepts only a
overwrite merge's temporary target coordinator or change a native control's contiguous prefix of the pre-minted data chain. Rebuildable `CreateIndex`
active-branch authority mid-operation. transactions may follow only the complete chain and are rollback-discardable
derived state; any other, unreadable, or non-contiguous transaction fails
closed. A compensating Lance `Restore` is also recognized by its exact target so
a crash after restore but before the manifest publish resumes without restoring
again.
The handle-local coordinator swap and `merge_exclusive` mutex remain an
implementation detail until target-context extraction lands; neither is treated
as persistent authority. Native ref create/delete still lack conditional CAS, so
first-touch destructive recovery retains the documented single-writer-process
boundary. `sync_branch` continues to join the schema gate and cannot replace the
temporary coordinator during a merge.
### Branch-delete orphaning exception ### Branch-delete orphaning exception
@ -334,16 +351,19 @@ are left at `Lance HEAD = manifest_pinned + 1`.
post_commit_pin)` it intends to commit + the writer kind + post_commit_pin)` it intends to commit + the writer kind +
actor_id. actor_id.
For a first-touch named-branch Mutation/Load table, Phase A is followed by For a first-touch named-branch Mutation/Load table, Phase A is followed by
target-ref creation and branch-local `stage_*`; the sidecar already carries target-ref creation and branch-local `stage_*`; the schema-v3 sidecar already
its pre-minted transaction identity. carries its pre-minted transaction identity. Branch merge uses schema v4:
it distinguishes multi-commit HEAD effects from ref-only forks, records each
multi-commit effect's ordered exact transaction chain, and records the
complete intended manifest delta, including pointer-only slots.
2. **Phase B**: writer's per-table `commit_staged` loop runs. 2. **Phase B**: writer's per-table `commit_staged` loop runs.
- **Phase-B confirmation:** a `BranchMerge` writer - **Phase-B confirmation:** a schema-v4 `BranchMerge` writer
advances each table's HEAD by *several* commits (append → upsert → advances each table's HEAD by *several* exact commits (append → upsert →
delete), so a bare "HEAD moved" is ambiguous — it could be a complete delete). Recovery proves a contiguous prefix of the pre-armed transaction
publish or one crashed mid-sequence. After the whole per-table loop chain rather than inferring ownership from numeric HEAD movement. After the
finishes, the writer re-writes the sidecar stamping each pin's whole per-table loop finishes, the writer atomically confirms each exact
`confirmed_version` with the exact achieved version, then proceeds to achieved version, the complete logical manifest delta, and first-touch ref
Phase C. Schema-v3 Mutation/Load sidecars also confirm: each table must identities, then proceeds to Phase C. Schema-v3 Mutation/Load sidecars also confirm: each table must
match the staged Lance transaction's `(read_version, uuid)`, and the match the staged Lance transaction's `(read_version, uuid)`, and the
sidecar records the exact `SubTableUpdate` plus original lineage intent. sidecar records the exact `SubTableUpdate` plus original lineage intent.
This is the commit point of the recovery WAL: a crash *after* confirmation This is the commit point of the recovery WAL: a crash *after* confirmation
@ -371,9 +391,16 @@ recovery sweep in `crates/omnigraph/src/db/manifest/recovery.rs`:
Lance HEAD to the manifest pin. Classify per the all-or-nothing Lance HEAD to the manifest pin. Classify per the all-or-nothing
decision tree (RolledPastExpected / NoMovement / UnexpectedAtP1 / decision tree (RolledPastExpected / NoMovement / UnexpectedAtP1 /
UnexpectedMultistep / IncompletePhaseB / InvariantViolation). For a UnexpectedMultistep / IncompletePhaseB / InvariantViolation). For a
`BranchMerge` sidecar, a moved HEAD with no `confirmed_version` classifies legacy `BranchMerge` sidecar, a moved HEAD with no `confirmed_version`
as `IncompletePhaseB` (a partial multi-commit publish) and forces roll-back; classifies as `IncompletePhaseB` (a partial multi-commit publish) and forces
with a `confirmed_version`, roll-forward targets exactly that version. roll-back; with a `confirmed_version`, roll-forward targets exactly that
version. Schema-v4 BranchMerge recovery additionally requires the captured
target token, fixed original/rollback lineage ids, the exact ordered data
transaction chains, exact confirmed physical effects, first-touch ref
identities, and the complete confirmed manifest delta. A changed target token
is rollback-only and can never re-parent the merge onto the winner. Recovery
refuses a foreign or non-contiguous transaction instead of restoring through
it, and recognizes an already-landed exact compensation restore on restart.
Schema-v3 Mutation/Load additionally requires `EffectsConfirmed`, the exact Schema-v3 Mutation/Load additionally requires `EffectsConfirmed`, the exact
Lance transaction identity at the confirmed version, the original immutable Lance transaction identity at the confirmed version, the original immutable
manifest delta, and a matching captured authority token. A changed token is manifest delta, and a matching captured authority token. A changed token is
@ -411,9 +438,10 @@ recovery sweep in `crates/omnigraph/src/db/manifest/recovery.rs`:
- After a successful roll-forward or roll-back, an internal - After a successful roll-forward or roll-back, an internal
`_graph_commit_recoveries.lance` row records `recovery_kind`, `_graph_commit_recoveries.lance` row records `recovery_kind`,
`recovery_for_actor` (the original sidecar's actor), `operation_id`, and `recovery_for_actor` (the original sidecar's actor), `operation_id`, and
exact per-table outcomes. A v3 roll-forward publishes the interrupted exact per-table outcomes. Schema-v3 Mutation/Load and schema-v4 BranchMerge
writer's fixed lineage intent, including its original actor; rollback and roll-forward publish the interrupted writer's fixed lineage intent,
legacy recovery commits use `actor_id = "omnigraph:recovery"`. Ordinary including its original actor; rollback and legacy recovery commits use
`actor_id = "omnigraph:recovery"`. Ordinary
commit history is therefore not a complete recovery enumeration, and the commit history is therefore not a complete recovery enumeration, and the
CLI currently has no public query for the recovery-audit table. CLI currently has no public query for the recovery-audit table.
- Sidecar deleted as the final step. - Sidecar deleted as the final step.
@ -469,7 +497,7 @@ guard says so explicitly ("a pending recovery sidecar requires
rollback — reopen the graph read-write") instead of pointing at rollback — reopen the graph read-write") instead of pointing at
`omnigraph repair`, which refuses while a sidecar is pending. `omnigraph repair`, which refuses while a sidecar is pending.
`cleanup` refuses pending sidecars at entry as well, before orphan reconciliation `cleanup` refuses pending sidecars at entry as well, before orphan reconciliation
or version GC: v3 ownership and compensation recovery may need the retained or version GC: v3/v4 ownership and compensation recovery may need the retained
Lance transaction/version history, so garbage collection cannot outrun the Lance transaction/version history, so garbage collection cannot outrun the
recovery barrier. recovery barrier.
Continuous in-process recovery for the rollback path is the goal of a Continuous in-process recovery for the rollback path is the goal of a
@ -477,15 +505,16 @@ future background reconciler. `ensure_indices` does not heal at entry itself;
it is an explicit maintenance/reconciliation call, separate from mutation, it is an explicit maintenance/reconciliation call, separate from mutation,
load, and schema apply, and its strict preconditions fail loudly on drift. load, and schema apply, and its strict preconditions fail loudly on drift.
For enrolled mutation/load, the publisher rechecks the attempt's exact native For enrolled mutation/load and branch merge, the publisher rechecks the
branch identity and `graph_head` as well as the touched-table versions. A attempt's exact native branch identity and `graph_head` as well as table
expectations. A
concurrent graph commit anywhere on the target branch therefore invalidates the concurrent graph commit anywhere on the target branch therefore invalidates the
prepared authority instead of silently reparenting it. Before effects, an prepared authority instead of silently reparenting it. Before effects, an
insert-only mutation or Append/Merge load fully reprepares with a bounded retry; strict insert-only mutation or Append/Merge load fully reprepares with a bounded retry; strict
Update/Delete/Overwrite returns `ReadSetChanged`; after any effect, any later Update/Delete/Overwrite and branch merge return `ReadSetChanged`; after any
error returns `RecoveryRequired` and leaves the fixed v3 intent durable. Legacy effect, any later error returns `RecoveryRequired` and leaves the fixed v3/v4
writers still arbitrate only their explicit touched-table expectations until intent durable. Schema apply and optimize/index remain on their writer-specific
their adapters are enrolled. arbitration until their adapters are enrolled.
**Sidecar I/O failure semantics** (all sidecar I/O goes through the **Sidecar I/O failure semantics** (all sidecar I/O goes through the
backend-generic `StorageAdapter`; the contracts below are pinned by the backend-generic `StorageAdapter`; the contracts below are pinned by the

View file

@ -694,14 +694,19 @@ Implementation proceeds in this order:
> **Implementation note (2026-07-11):** mutation/load now use this coarse > **Implementation note (2026-07-11):** mutation/load now use this coarse
> token, schema-v3 exact-effect sidecars, fixed lineage/rollback outcome ids, > token, schema-v3 exact-effect sidecars, fixed lineage/rollback outcome ids,
> zero transparent Lance commit retries, and bounded full reprepare before > zero transparent Lance commit retries, and bounded full reprepare before
> effects. Branch merge remains on its writer-specific multi-commit path, but > effects. Branch merge now captures an immutable source commit/snapshot and
> now holds the root-shared schema plus source/target branch gates from its > the target coarse token, computes its merge base from those captured ids,
> strict recovery barrier and authority capture through publication. It plans > and revalidates target authority plus source incarnation under the ordered
> with an accepted-contract catalog captured under that schema gate, then > gates. Its schema-v4 recovery envelope distinguishes multi-commit HEAD
> acquires all catalog table gates for source and target, re-lists recovery, > effects from first-touch ref-only forks, persists an ordered pre-minted
> and compares fresh manifest versions before Phase A. This closes > Lance transaction chain for every logical data effect plus fixed
> same-process delete/recreate ABA and legacy table-only-writer races while > merge/rollback lineage, and confirms the complete manifest delta (including
> its full exact-effect adapter remains future work. Schema apply, > pointer-only slots) before publishing with `ExactGraphHead`. Those data
> transactions commit with zero transparent conflict retries; recovery accepts
> only their contiguous exact prefix (plus a derived `CreateIndex` tail after
> the complete chain) and fails closed on foreign movement. A pre-effect target change
> returns `ReadSetChanged`; any post-arm failure returns `RecoveryRequired`
> and recovery never re-parents onto a target winner. Schema apply,
> optimize/index, and MemWAL fold remain on their writer-specific paths until > optimize/index, and MemWAL fold remain on their writer-specific paths until
> their adapter slices land. > their adapter slices land.

View file

@ -19,7 +19,7 @@ Two primitives, two scopes:
| **One `.gq` query** (any number of statements inside) | The query itself — handled by the publisher's atomic manifest commit | Yes — all statements land together or none of them do | The publisher never publishes; target unchanged | | **One `.gq` query** (any number of statements inside) | The query itself — handled by the publisher's atomic manifest commit | Yes — all statements land together or none of them do | The publisher never publishes; target unchanged |
| **Many queries that must succeed together** | Branches: `branch_create` → run N queries on the branch → `branch_merge` | Yes — the merge is a single atomic publish | Drop the branch (`branch_delete`); main is unaffected | | **Many queries that must succeed together** | Branches: `branch_create` → run N queries on the branch → `branch_merge` | Yes — the merge is a single atomic publish | Drop the branch (`branch_delete`); main is unaffected |
Snapshot isolation is per-query — every read inside one query sees one consistent manifest version. Two concurrent queries on the same branch see independent snapshots. Mutation/load capture the branch head as coarse OCC authority, so a prepared plan is never silently reparented after another graph commit. Snapshot isolation is per-query — every read inside one query sees one consistent manifest version. Two concurrent queries on the same branch see independent snapshots. Mutation/load and branch merge capture the target branch head as coarse OCC authority, so a prepared plan is never silently reparented after another graph commit. Merge additionally pins the exact source commit it classified; a later source advance is not substituted into the prepared result.
## Comparison with `BEGIN` / `COMMIT` ## Comparison with `BEGIN` / `COMMIT`

View file

@ -5,6 +5,7 @@
| `MANIFEST_DIR` | `__manifest` | manifest layout | | `MANIFEST_DIR` | `__manifest` | manifest layout |
| Commit graph dirs (retired) | `_graph_commits.lance` / `_graph_commit_actors.lance` | retired in Phase B; lineage lives in `__manifest` (`graph_commit` / `graph_head` rows) since RFC-013 Phase 7. A graph this binary creates has neither. | | Commit graph dirs (retired) | `_graph_commits.lance` / `_graph_commit_actors.lance` | retired in Phase B; lineage lives in `__manifest` (`graph_commit` / `graph_head` rows) since RFC-013 Phase 7. A graph this binary creates has neither. |
| Recovery audit dir | `_graph_commit_recoveries.lance` | internal exact record of completed crash-recovery actions; no public CLI query yet | | Recovery audit dir | `_graph_commit_recoveries.lance` | internal exact record of completed crash-recovery actions; no public CLI query yet |
| Exact recovery history-scan ceiling | `MAX_EFFECT_IDENTITY_SCAN_VERSIONS = 1024` | v3/v4 recovery fails closed as unverifiable rather than scanning an unbounded Lance transaction history; a v4 logical merge chain is at most three commits today, with only derived `CreateIndex` commits allowed after it |
| Run branch prefix (legacy, removed) | `__run__` | pre-v0.4.0 Run state machine; no longer a reserved name. A graph still carrying `__run__*` branches is sub-v4 and refused on open (rebuild via export/import). | | Run branch prefix (legacy, removed) | `__run__` | pre-v0.4.0 Run state machine; no longer a reserved name. A graph still carrying `__run__*` branches is sub-v4 and refused on open (rebuild via export/import). |
| Schema apply lock | `__schema_apply_lock__` | schema apply | | Schema apply lock | `__schema_apply_lock__` | schema apply |
| Manifest publisher retry budget | `PUBLISHER_RETRY_BUDGET = 5` | manifest publish | | Manifest publisher retry budget | `PUBLISHER_RETRY_BUDGET = 5` | manifest publish |