mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-07-12 03:12:11 +02:00
Enroll branch merge in RFC-022 write path (#345)
This commit is contained in:
parent
bd4c614e42
commit
e0e145aa92
22 changed files with 3722 additions and 464 deletions
|
|
@ -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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -134,6 +134,45 @@ impl CommitGraph {
|
|||
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();
|
||||
for commit in source.load_commits().await? {
|
||||
commits.insert(commit.graph_commit_id.clone(), commit);
|
||||
|
|
@ -142,8 +181,12 @@ impl CommitGraph {
|
|||
commits.insert(commit.graph_commit_id.clone(), commit);
|
||||
}
|
||||
|
||||
let source_distances = ancestor_distances(&source_head.graph_commit_id, &commits);
|
||||
let target_distances = ancestor_distances(&target_head.graph_commit_id, &commits);
|
||||
if !commits.contains_key(source_commit_id) || !commits.contains_key(target_commit_id) {
|
||||
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
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// fresh ULID (stable across the publisher's CAS retries) and a timestamp.
|
||||
/// The parent is NOT chosen here — the publisher resolves it per attempt
|
||||
|
|
|
|||
|
|
@ -38,10 +38,12 @@ use namespace::{branch_manifest_namespace, staged_table_namespace};
|
|||
pub(crate) use publisher::{GraphHeadExpectation, LineageIntent, PublishPrecondition};
|
||||
use publisher::{GraphNamespacePublisher, ManifestBatchPublisher, PublishOutcome};
|
||||
pub(crate) use recovery::{
|
||||
HealPendingOutcome, RecoveryAuthorityToken, RecoveryLineageIntent, RecoveryMode,
|
||||
RecoverySidecar, RecoverySidecarHandle, SidecarKind, SidecarTablePin, SidecarTableRegistration,
|
||||
SidecarTombstone, confirm_occ_sidecar_phase_b, confirm_sidecar_phase_b, delete_sidecar,
|
||||
has_schema_apply_sidecar, heal_pending_sidecars_roll_forward, list_sidecars, new_occ_sidecar,
|
||||
HealPendingOutcome, RecoveryAuthorityToken, RecoveryBranchMergeEffect,
|
||||
RecoveryBranchMergeEffectKind, RecoveryLineageIntent, RecoveryManifestDelta, RecoveryMode,
|
||||
RecoverySidecar, RecoverySidecarHandle, RecoveryTableUpdateSlot, SidecarKind, SidecarTablePin,
|
||||
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,
|
||||
};
|
||||
pub use state::SubTableEntry;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -131,6 +131,11 @@ pub(crate) struct WriteTxn {
|
|||
pub(crate) base: Snapshot,
|
||||
/// Complete coarse authority token for this prepared attempt.
|
||||
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
|
||||
/// `authority`. Mutation/load planning and validation must use this snapshot,
|
||||
/// never the handle-global catalog, which can lag a schema apply performed by
|
||||
|
|
@ -894,7 +899,7 @@ impl Omnigraph {
|
|||
.await?;
|
||||
let (schema_ir, schema_state) =
|
||||
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)
|
||||
.await?;
|
||||
self.ensure_schema_apply_not_locked("write preparation")
|
||||
|
|
@ -918,6 +923,7 @@ impl Omnigraph {
|
|||
schema_ir_hash: schema_state.schema_ir_hash,
|
||||
schema_identity_version: schema_state.schema_identity_version,
|
||||
},
|
||||
effective_graph_head,
|
||||
catalog: Arc::new(catalog),
|
||||
});
|
||||
}
|
||||
|
|
@ -972,6 +978,7 @@ impl Omnigraph {
|
|||
) -> Result<(
|
||||
lance::dataset::refs::BranchIdentifier,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Snapshot,
|
||||
)> {
|
||||
{
|
||||
|
|
@ -987,6 +994,10 @@ impl Omnigraph {
|
|||
return Ok((
|
||||
coord.branch_identifier().await?,
|
||||
coord.exact_graph_head(),
|
||||
coord
|
||||
.head_commit_id()
|
||||
.await?
|
||||
.map(|head| head.as_str().to_string()),
|
||||
coord.snapshot(),
|
||||
));
|
||||
}
|
||||
|
|
@ -997,6 +1008,10 @@ impl Omnigraph {
|
|||
Ok((
|
||||
coord.branch_identifier().await?,
|
||||
coord.exact_graph_head(),
|
||||
coord
|
||||
.head_commit_id()
|
||||
.await?
|
||||
.map(|head| head.as_str().to_string()),
|
||||
coord.snapshot(),
|
||||
))
|
||||
}
|
||||
|
|
@ -1008,7 +1023,7 @@ impl Omnigraph {
|
|||
// Recheck the durable sentinel inside that critical section so a schema
|
||||
// apply observed after preparation cannot be followed by a table effect.
|
||||
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)
|
||||
.await?;
|
||||
let schema_state = self.ensure_schema_state_valid().await?;
|
||||
|
|
@ -1969,17 +1984,6 @@ impl Omnigraph {
|
|||
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<()> {
|
||||
self.branch_create_as(name, None).await
|
||||
}
|
||||
|
|
@ -2357,19 +2361,6 @@ impl Omnigraph {
|
|||
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(
|
||||
&self,
|
||||
branch: Option<&str>,
|
||||
|
|
|
|||
|
|
@ -1460,20 +1460,6 @@ pub(super) async fn commit_updates(
|
|||
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
|
||||
/// `expected_table_versions` map asserts the manifest's pre-write per-table
|
||||
/// versions; mismatches surface as `ManifestConflictDetails::ExpectedVersionMismatch`.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
target_db: &Omnigraph,
|
||||
table_key: &str,
|
||||
staged: &StagedMergeResult,
|
||||
planned_transactions: &[crate::table_store::StagedTransactionIdentity],
|
||||
) -> Result<crate::db::SubTableUpdate> {
|
||||
// Branch merge's source-rewrite path is Merge-shaped (upsert from
|
||||
// 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
|
||||
// here.
|
||||
// `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
|
||||
// existing rows, bumps _row_last_updated_at_version only for actually-changed rows).
|
||||
//
|
||||
// Routed through the staged primitive so a failure between writing
|
||||
// fragments and committing leaves no Lance-HEAD drift. The
|
||||
// commit_staged here is per-table per-call (Lance has no
|
||||
// multi-dataset atomic commit); the residual sits at this single
|
||||
// commit point, narrowed from the previous "merge_insert + delete +
|
||||
// index" multi-step inline-commit chain.
|
||||
// Routed through the staged primitive with the transaction identity armed
|
||||
// in the v4 sidecar and transparent Lance conflict retries disabled. A
|
||||
// failure between writing fragments and committing leaves no Lance-HEAD
|
||||
// drift; a failure after the exact commit remains recovery-owned.
|
||||
let mut planned_transactions = planned_transactions.iter();
|
||||
if let Some(delta) = &staged.delta_staged {
|
||||
// The staged delta dataset is a temp-dir Lance dataset used only
|
||||
// to collect the rewrite batches; wrap it in a `SnapshotHandle`
|
||||
|
|
@ -976,10 +1045,13 @@ async fn publish_rewritten_merge_table(
|
|||
lance::dataset::WhenNotMatched::InsertAll,
|
||||
)
|
||||
.await?;
|
||||
current_ds = target_db
|
||||
.storage()
|
||||
.commit_staged(current_ds, staged_merge)
|
||||
.await?;
|
||||
let planned = planned_transactions.next().ok_or_else(|| {
|
||||
OmniError::manifest_internal(format!(
|
||||
"branch merge table '{table_key}' has no planned merge transaction"
|
||||
))
|
||||
})?;
|
||||
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
|
||||
// achieved-version intent has not been recorded, so recovery must roll 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
|
||||
// `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
|
||||
// staged write, so this no longer inline-commits).
|
||||
if !staged.deleted_ids.is_empty() {
|
||||
|
|
@ -1001,27 +1075,45 @@ async fn publish_rewritten_merge_table(
|
|||
.map(|id| format!("'{}'", id.replace('\'', "''")))
|
||||
.collect();
|
||||
let filter = format!("id IN ({})", escaped.join(", "));
|
||||
if let Some(staged_delete) = target_db.storage().stage_delete(¤t_ds, &filter).await? {
|
||||
current_ds = target_db
|
||||
.storage()
|
||||
.commit_staged(current_ds, staged_delete)
|
||||
.await?;
|
||||
if let Some(staged_delete) = target_db
|
||||
.storage()
|
||||
.stage_delete(¤t_ds, &filter)
|
||||
.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.
|
||||
// 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
|
||||
// recorded, so recovery must roll BACK (the index is reconciler-owned derived
|
||||
// state, but the merge itself never reached its commit boundary). See
|
||||
// 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.
|
||||
//
|
||||
// `build_indices_on_dataset` uses `stage_create_btree_index` /
|
||||
// `stage_create_inverted_index` + `commit_staged` for scalar
|
||||
// indices. Vector indices remain inline-commit
|
||||
// `stage_create_inverted_index` + `commit_staged` for scalar indices.
|
||||
// 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-
|
||||
// 6.0.1 — companion ticket to lance-format/lance#6666).
|
||||
let row_count = target_db
|
||||
|
|
@ -1095,6 +1187,7 @@ async fn publish_adopted_delta(
|
|||
target_db: &Omnigraph,
|
||||
table_key: &str,
|
||||
delta: &AdoptDelta,
|
||||
planned_transactions: &[crate::table_store::StagedTransactionIdentity],
|
||||
) -> Result<crate::db::SubTableUpdate> {
|
||||
// `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
|
||||
|
|
@ -1115,16 +1208,19 @@ async fn publish_adopted_delta(
|
|||
// through the staged primitive so a failure between writing fragments and
|
||||
// committing leaves no Lance-HEAD drift. `appends` is `Some` only when the
|
||||
// staged table is non-empty (`compute_adopt_delta`).
|
||||
let mut planned_transactions = planned_transactions.iter();
|
||||
if let Some(append_table) = &delta.appends {
|
||||
let source = SnapshotHandle::new(append_table.dataset.clone());
|
||||
let staged = target_db
|
||||
.storage()
|
||||
.stage_append_stream(¤t_ds, &source, &[])
|
||||
.await?;
|
||||
current_ds = target_db
|
||||
.storage()
|
||||
.commit_staged(current_ds, staged)
|
||||
.await?;
|
||||
let planned = planned_transactions.next().ok_or_else(|| {
|
||||
OmniError::manifest_internal(format!(
|
||||
"branch merge table '{table_key}' has no planned append transaction"
|
||||
))
|
||||
})?;
|
||||
current_ds = commit_exact_merge_stage(target_db, current_ds, staged, planned).await?;
|
||||
}
|
||||
|
||||
// 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
|
||||
// recovery must roll BACK (not publish the appends-only state). See
|
||||
// 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
|
||||
// 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
|
||||
// disjoint from the appended ids (each id is classified into exactly one of
|
||||
// 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(combined) = scan_staged_combined(target_db, upsert_table).await? {
|
||||
let staged_merge = target_db
|
||||
|
|
@ -1152,10 +1251,13 @@ async fn publish_adopted_delta(
|
|||
lance::dataset::WhenNotMatched::InsertAll,
|
||||
)
|
||||
.await?;
|
||||
current_ds = target_db
|
||||
.storage()
|
||||
.commit_staged(current_ds, staged_merge)
|
||||
.await?;
|
||||
let planned = planned_transactions.next().ok_or_else(|| {
|
||||
OmniError::manifest_internal(format!(
|
||||
"branch merge table '{table_key}' has no planned upsert transaction"
|
||||
))
|
||||
})?;
|
||||
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
|
||||
// recovery must roll BACK. See
|
||||
// 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
|
||||
// `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() {
|
||||
let escaped: Vec<String> = delta
|
||||
.deleted_ids
|
||||
|
|
@ -1175,14 +1280,27 @@ async fn publish_adopted_delta(
|
|||
.map(|id| format!("'{}'", id.replace('\'', "''")))
|
||||
.collect();
|
||||
let filter = format!("id IN ({})", escaped.join(", "));
|
||||
if let Some(staged_delete) = target_db.storage().stage_delete(¤t_ds, &filter).await? {
|
||||
current_ds = target_db
|
||||
.storage()
|
||||
.commit_staged(current_ds, staged_delete)
|
||||
.await?;
|
||||
if let Some(staged_delete) = target_db
|
||||
.storage()
|
||||
.stage_delete(¤t_ds, &filter)
|
||||
.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
|
||||
// three-way `RewriteMerged` path, this does NOT build indices inline: the
|
||||
// appended/upserted rows are left uncovered (reads stay correct via
|
||||
|
|
@ -1273,32 +1391,37 @@ impl Omnigraph {
|
|||
.write_queue()
|
||||
.acquire_branches(&[source_branch.clone(), target_branch.clone()])
|
||||
.await;
|
||||
self.ensure_no_pending_recovery_sidecars_under_gates(
|
||||
&relevant_branches,
|
||||
"branch_merge",
|
||||
)
|
||||
.await?;
|
||||
self.ensure_no_pending_recovery_sidecars_under_gates(&relevant_branches, "branch_merge")
|
||||
.await?;
|
||||
self.refresh_coordinator_only().await?;
|
||||
self.ensure_schema_apply_not_locked("branch_merge").await?;
|
||||
let merge_catalog = self
|
||||
.load_accepted_catalog_with_schema_gate_held()
|
||||
.await?;
|
||||
|
||||
let source_head_commit_id = self
|
||||
.head_commit_id_for_branch(source_branch.as_deref())
|
||||
.await?
|
||||
// Capture each branch as one coherent RFC-022 authority token plus
|
||||
// immutable snapshot. The target token is the coarse publish read set;
|
||||
// the source token pins the exact merge input without requiring the
|
||||
// source head to remain latest until the target CAS.
|
||||
let source_txn = self.open_write_txn(source_branch.as_deref()).await?;
|
||||
let target_txn = self.open_write_txn(target_branch.as_deref()).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()))?;
|
||||
let target_head_commit_id = self
|
||||
.head_commit_id_for_branch(target_branch.as_deref())
|
||||
.await?
|
||||
let target_head_commit_id = target_txn
|
||||
.effective_graph_head
|
||||
.clone()
|
||||
.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(),
|
||||
source_branch.as_deref(),
|
||||
target_branch.as_deref(),
|
||||
&source_head_commit_id,
|
||||
&target_head_commit_id,
|
||||
)
|
||||
.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
|
||||
|| base_commit.graph_commit_id == source_head_commit_id
|
||||
|
|
@ -1313,18 +1436,6 @@ impl Omnigraph {
|
|||
base_commit.manifest_version,
|
||||
)
|
||||
.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::names::BRANCH_MERGE_POST_AUTHORITY_CAPTURE,
|
||||
)?;
|
||||
|
|
@ -1345,9 +1456,8 @@ impl Omnigraph {
|
|||
let merge_result = self
|
||||
.branch_merge_on_current_target(
|
||||
&base_snapshot,
|
||||
&source_snapshot,
|
||||
&target_snapshot,
|
||||
merge_catalog.as_ref(),
|
||||
&source_txn,
|
||||
&target_txn,
|
||||
source_branch.as_deref(),
|
||||
target_branch.as_deref(),
|
||||
&target_head_commit_id,
|
||||
|
|
@ -1377,7 +1487,7 @@ impl Omnigraph {
|
|||
// Use `refresh_coordinator_only` rather than `refresh` so the
|
||||
// recovery sweep doesn't race the merge's own in-flight
|
||||
// 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
|
||||
// disk. `refresh`'s `RollForwardOnly` sweep would observe it
|
||||
// and close it here — masking the failure from the next
|
||||
|
|
@ -1411,9 +1521,8 @@ impl Omnigraph {
|
|||
async fn branch_merge_on_current_target(
|
||||
&self,
|
||||
base_snapshot: &Snapshot,
|
||||
source_snapshot: &Snapshot,
|
||||
target_snapshot: &Snapshot,
|
||||
catalog: &Catalog,
|
||||
source_txn: &WriteTxn,
|
||||
target_txn: &WriteTxn,
|
||||
source_branch: Option<&str>,
|
||||
target_branch: Option<&str>,
|
||||
target_head_commit_id: &str,
|
||||
|
|
@ -1421,6 +1530,9 @@ impl Omnigraph {
|
|||
is_fast_forward: bool,
|
||||
actor_id: Option<&str>,
|
||||
) -> 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();
|
||||
for entry in base_snapshot.entries() {
|
||||
table_keys.insert(entry.table_key.clone());
|
||||
|
|
@ -1486,25 +1598,14 @@ impl Omnigraph {
|
|||
let changeset = build_merge_changeset(self, catalog, &candidates).await?;
|
||||
validate_merge_candidates(catalog, target_snapshot, &changeset).await?;
|
||||
|
||||
// Recovery sidecar: protect the per-table commit_staged loop.
|
||||
// Pin `RewriteMerged` and `AdoptWithDelta` candidates — both advance
|
||||
// Lance HEAD before the manifest publish (RewriteMerged via
|
||||
// publish_rewritten_merge_table; AdoptWithDelta via publish_adopted_delta:
|
||||
// stage_append + stage_merge_insert + stage_delete + index — multiple
|
||||
// commit_staged calls per table, which the loose classification handles
|
||||
// as multi-step drift).
|
||||
//
|
||||
// `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`.
|
||||
// Recovery sidecar: protect the complete physical effect set. Every
|
||||
// `RewriteMerged` / `AdoptWithDelta` logical data step receives a
|
||||
// pre-minted, sequential Lance transaction identity and commits with
|
||||
// transparent retries disabled. Ref-only `AdoptSourceState` candidates
|
||||
// have no data transaction, but a first-touch native target ref is still
|
||||
// an independently durable v4 effect. Pointer-only manifest updates need
|
||||
// no physical pin; they remain in the complete intended delta so a
|
||||
// sibling effect's recovery publishes the merge atomically.
|
||||
//
|
||||
// This bridge still coexists with legacy maintenance writers that take
|
||||
// only `(table, branch)` queues. Acquire the conservative all-catalog
|
||||
|
|
@ -1525,101 +1626,228 @@ impl Omnigraph {
|
|||
"branch_merge after acquiring source/target table gates",
|
||||
)
|
||||
.await?;
|
||||
let fresh_source_snapshot = self
|
||||
.fresh_snapshot_for_branch_unchecked(source_branch)
|
||||
.await?;
|
||||
let fresh_target_snapshot = self
|
||||
.fresh_snapshot_for_branch_unchecked(target_branch)
|
||||
.await?;
|
||||
for (member, prepared, current) in [
|
||||
("source", source_snapshot, &fresh_source_snapshot),
|
||||
("target", target_snapshot, &fresh_target_snapshot),
|
||||
] {
|
||||
if prepared.version() != current.version() {
|
||||
return Err(OmniError::manifest_read_set_changed(
|
||||
format!(
|
||||
"branch_merge_{member}:{}",
|
||||
if member == "source" {
|
||||
source_branch.unwrap_or("main")
|
||||
} else {
|
||||
target_branch.unwrap_or("main")
|
||||
}
|
||||
),
|
||||
Some(prepared.version().to_string()),
|
||||
Some(current.version().to_string()),
|
||||
));
|
||||
if target_snapshot.version() != fresh_target_snapshot.version() {
|
||||
return Err(OmniError::manifest_read_set_changed(
|
||||
format!("branch_merge_target:{}", target_branch.unwrap_or("main")),
|
||||
Some(target_snapshot.version().to_string()),
|
||||
Some(fresh_target_snapshot.version().to_string()),
|
||||
));
|
||||
}
|
||||
// Revalidate the complete target authority (branch incarnation, exact
|
||||
// graph head, and schema identity), not only its numeric manifest
|
||||
// version. This is the same coarse token mutation/load publish under.
|
||||
self.revalidate_write_txn(target_txn).await?;
|
||||
|
||||
// Source is an immutable input, not part of the target CAS. A later
|
||||
// source-head advance is therefore harmless, but delete/recreate ABA is
|
||||
// not: the captured snapshot must still belong to the same native ref
|
||||
// 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
|
||||
.iter()
|
||||
.filter_map(|table_key| {
|
||||
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).
|
||||
// Keep the sidecar alongside its handle: after the whole physical
|
||||
// effect set completes, confirmation binds every output slot and every
|
||||
// first-touch ref identity before the one manifest CAS.
|
||||
let mut recovery: Option<(
|
||||
crate::db::manifest::RecoverySidecar,
|
||||
crate::db::manifest::RecoverySidecarHandle,
|
||||
)> = if recovery_pins.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Use the merge target branch directly, NOT a heuristic
|
||||
// derived from `ordered_table_keys.first()`. The first
|
||||
// sorted table key may not be in the target snapshot at all
|
||||
// (its `entry()` returns None → branch becomes None == main),
|
||||
// and the SubTableEntry's `table_branch` field isn't
|
||||
// necessarily the merge target branch. The caller
|
||||
// `branch_merge` calls `swap_coordinator_for_branch(target_branch)`
|
||||
// before invoking this function, so `self.active_branch()`
|
||||
// is the target.
|
||||
let target_branch = active_branch_for_keys.clone();
|
||||
let mut sidecar = crate::db::manifest::new_sidecar(
|
||||
crate::db::manifest::SidecarKind::BranchMerge,
|
||||
target_branch,
|
||||
let authority = crate::db::manifest::RecoveryAuthorityToken {
|
||||
branch_identifier: target_txn.authority.branch_identifier.clone(),
|
||||
graph_head: target_txn.authority.graph_head.clone(),
|
||||
schema_ir_hash: target_txn.authority.schema_ir_hash.clone(),
|
||||
schema_identity_version: target_txn.authority.schema_identity_version,
|
||||
};
|
||||
let recovery_lineage = crate::db::manifest::RecoveryLineageIntent {
|
||||
graph_commit_id: merge_lineage.graph_commit_id.clone(),
|
||||
branch: merge_lineage.branch.clone(),
|
||||
actor_id: merge_lineage.actor_id.clone(),
|
||||
merged_parent_commit_id: merge_lineage.merged_parent_commit_id.clone(),
|
||||
created_at: merge_lineage.created_at,
|
||||
};
|
||||
let sidecar = crate::db::manifest::new_branch_merge_sidecar(
|
||||
active_branch_for_keys.clone(),
|
||||
actor_id.map(str::to_string),
|
||||
recovery_pins,
|
||||
);
|
||||
// Carry the source branch's HEAD commit id so the recovery
|
||||
// sweep's audit step can record this as a MERGE commit
|
||||
// (linked to the source) instead of a plain commit. Without
|
||||
// this, future merges between the same pair lose
|
||||
// already-up-to-date detection and merge-base correctness.
|
||||
sidecar.merge_source_commit_id = Some(source_head_commit_id.to_string());
|
||||
authority,
|
||||
recovery_lineage,
|
||||
recovery_effects,
|
||||
crate::db::manifest::RecoveryManifestDelta {
|
||||
table_updates: delta_slots,
|
||||
registrations: Vec::new(),
|
||||
tombstones: Vec::new(),
|
||||
},
|
||||
)?;
|
||||
let handle = crate::db::manifest::write_sidecar(
|
||||
self.root_uri(),
|
||||
self.storage_adapter(),
|
||||
|
|
@ -1629,72 +1857,134 @@ impl Omnigraph {
|
|||
Some((sidecar, handle))
|
||||
};
|
||||
|
||||
let mut updates = Vec::new();
|
||||
let mut changed_edge_tables = false;
|
||||
for table_key in &ordered_table_keys {
|
||||
let Some(candidate_state) = candidates.get(table_key) else {
|
||||
continue;
|
||||
};
|
||||
let update = match candidate_state {
|
||||
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;
|
||||
let recovery_operation_id = recovery
|
||||
.as_ref()
|
||||
.map(|(_, handle)| handle.operation_id.clone());
|
||||
let post_arm_result = async {
|
||||
if recovery.is_some() && !first_touch_effects.is_empty() {
|
||||
crate::failpoints::maybe_fail(
|
||||
crate::failpoints::names::BRANCH_MERGE_POST_SIDECAR_PRE_FORK,
|
||||
)?;
|
||||
}
|
||||
updates.push(update);
|
||||
}
|
||||
|
||||
// Phase-B confirmation: every table's publish finished, so stamp the
|
||||
// sidecar with each table's exact achieved version before the manifest
|
||||
// publish. This is the commit point of the recovery WAL: a crash from
|
||||
// here on rolls FORWARD to these versions, while a crash anywhere in the
|
||||
// publish loop above left the sidecar unconfirmed and rolls BACK. The
|
||||
// `updates` carry the real per-table final versions (multiple
|
||||
// commit_staged calls per table, so not derivable from `post_commit_pin`
|
||||
// alone). A failure here leaves the unconfirmed sidecar → roll back.
|
||||
crate::failpoints::maybe_fail(
|
||||
crate::failpoints::names::BRANCH_MERGE_POST_EFFECTS_PRE_CONFIRM,
|
||||
)?;
|
||||
if let Some((sidecar, _)) = recovery.as_mut() {
|
||||
let confirmed_versions: std::collections::HashMap<String, u64> = updates
|
||||
.iter()
|
||||
.map(|u| (u.table_key.clone(), u.table_version))
|
||||
.collect();
|
||||
crate::db::manifest::confirm_sidecar_phase_b(
|
||||
self.root_uri(),
|
||||
self.storage_adapter(),
|
||||
sidecar,
|
||||
&confirmed_versions,
|
||||
let mut updates = Vec::new();
|
||||
let mut changed_edge_tables = false;
|
||||
let mut confirmed_ref_identifiers = HashMap::new();
|
||||
for table_key in &ordered_table_keys {
|
||||
let Some(candidate_state) = candidates.get(table_key) else {
|
||||
continue;
|
||||
};
|
||||
let update = match candidate_state {
|
||||
CandidateTableState::AdoptSourceState { .. } => {
|
||||
publish_adopted_source_state(
|
||||
self,
|
||||
source_snapshot,
|
||||
target_snapshot,
|
||||
table_key,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
CandidateTableState::AdoptWithDelta(delta) => {
|
||||
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_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?;
|
||||
|
||||
Ok::<_, OmniError>((updates, changed_edge_tables))
|
||||
}
|
||||
|
||||
// Failpoint: pin the per-writer Phase B → Phase C residual for
|
||||
// branch_merge. Lance HEAD has advanced on every touched table
|
||||
// (publish_*) AND the sidecar is confirmed, but the manifest publish
|
||||
// below hasn't run — so recovery rolls FORWARD. Used by
|
||||
// `tests/failpoints.rs::branch_merge_phase_b_failure_recovered_on_next_open`.
|
||||
crate::failpoints::maybe_fail(crate::failpoints::names::BRANCH_MERGE_POST_PHASE_B_PRE_MANIFEST_COMMIT)?;
|
||||
|
||||
// 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
|
||||
// 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?;
|
||||
.await;
|
||||
let (_updates, changed_edge_tables) = match post_arm_result {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
return match recovery_operation_id {
|
||||
Some(operation_id) => Err(OmniError::recovery_required(
|
||||
operation_id,
|
||||
error.to_string(),
|
||||
)),
|
||||
None => Err(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Recovery sidecar lifecycle: delete after the manifest publish (Phase C).
|
||||
// Best-effort cleanup; the merge already landed durably so failing the
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ use time::format_description::well_known::Rfc3339;
|
|||
|
||||
use crate::db::commit_graph::CommitGraph;
|
||||
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::embedding::EmbeddingClient;
|
||||
use crate::error::{MergeConflict, MergeConflictKind, OmniError, Result};
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ pub mod names {
|
|||
/// any durable table effect.
|
||||
pub const BRANCH_MERGE_POST_AUTHORITY_CAPTURE: &str =
|
||||
"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 =
|
||||
"branch_merge.post_phase_b_pre_manifest_commit";
|
||||
/// 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.
|
||||
pub const RECOVERY_POST_ROLLBACK_PUBLISH_PRE_AUDIT: &str =
|
||||
"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_SIDECAR_CONFIRM: &str = "recovery.sidecar_confirm";
|
||||
pub const RECOVERY_SIDECAR_DELETE: &str = "recovery.sidecar_delete";
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ use lance::dataset::scanner::{ColumnOrdering, DatasetRecordBatchStream};
|
|||
use lance::dataset::{WhenMatched, WhenNotMatched};
|
||||
|
||||
use crate::db::{Snapshot, SubTableEntry};
|
||||
use crate::error::Result;
|
||||
use crate::error::{OmniError, Result};
|
||||
use crate::table_store::{StagedTransactionIdentity, StagedWrite, TableState, TableStore};
|
||||
|
||||
// ─── sealed module ──────────────────────────────────────────────────────────
|
||||
|
|
@ -286,6 +286,14 @@ pub trait TableStorage: sealed::Sealed + Send + Sync + Debug {
|
|||
branch: Option<&str>,
|
||||
) -> 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(
|
||||
&self,
|
||||
dataset_uri: &str,
|
||||
|
|
@ -564,6 +572,17 @@ impl TableStorage for TableStore {
|
|||
.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(
|
||||
&self,
|
||||
dataset_uri: &str,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -16,8 +16,9 @@ pub enum RecoveryExpectation {
|
|||
RolledForward {
|
||||
tables: Vec<TableExpectation>,
|
||||
},
|
||||
/// Protocol-v3 mutation/load recovery republishes the writer's fixed
|
||||
/// lineage intent rather than minting a synthetic recovery commit.
|
||||
/// RFC-022 v3 mutation/load and v4 branch-merge recovery republish the
|
||||
/// writer's fixed lineage intent rather than minting a synthetic recovery
|
||||
/// commit.
|
||||
RolledForwardOriginalLineage {
|
||||
tables: Vec<TableExpectation>,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ where
|
|||
/// 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
|
||||
/// 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]
|
||||
fn merge_validation_is_delta_scoped() {
|
||||
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
|
||||
// tables (the delta + the committed-target index probe for uniqueness) —
|
||||
// never the untouched Company / Knows / WorksAt. Pre-#5 this was ~6
|
||||
// (every catalog table, full-scanned).
|
||||
// tables (the delta + committed-target index probe + exact confirmation
|
||||
// re-observation) — never the untouched Company / Knows / WorksAt.
|
||||
// Pre-#5 this was ~6 (every catalog table, full-scanned).
|
||||
assert!(
|
||||
io.data_open_count <= 3,
|
||||
"merge of a 1-Person delta opened {} data tables; expected <= 3 (Δ-scoped). \
|
||||
io.data_open_count <= 4,
|
||||
"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.",
|
||||
io.data_open_count
|
||||
);
|
||||
|
|
|
|||
|
|
@ -258,10 +258,15 @@ them explicit.
|
|||
schema-apply lock branch) — design it before promoting multi-process write
|
||||
topologies.
|
||||
- **Fork ownership is durable, but Lance ref deletion is not conditional:** a
|
||||
first-touch Mutation/Load table no longer creates its target ref while
|
||||
preparing. Under schema → branch → table gates it revalidates, durably arms a
|
||||
schema-v3 sidecar naming the target, creates the ref, then stages branch-local
|
||||
files and commits. Both `reclaim_orphaned_fork_and_refork` and
|
||||
first-touch Mutation/Load or BranchMerge table never creates its target ref
|
||||
before recovery ownership is durable. Under schema → branch → table gates it
|
||||
revalidates and arms a schema-v3 mutation/load or schema-v4 merge sidecar
|
||||
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
|
||||
foreign claim is conflict/indeterminate, never permission to delete. Full
|
||||
recovery accepts sidecar-before-ref crashes. When `BranchContents` is absent,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ The implementation spec's hard gate for this phase — failpoint recovery tests
|
|||
|
||||
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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
**Status:** Closed in specification on 2026-07-11; full merge-adapter conversion
|
||||
remains rollout work rather than an architecture ambiguity.
|
||||
**Status:** Closed in specification and implementation on 2026-07-11. Branch
|
||||
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
|
||||
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
|
||||
> 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
|
||||
|
||||
RFC-023 correctly identifies Lance's directional filtered/unfiltered behavior.
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
| `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) |
|
||||
| `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` |
|
||||
| `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). |
|
||||
|
||||
## Fixtures
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
confirmation sidecar; it has not yet been converted to the RFC-022 exact-effect
|
||||
adapter. It does, however, join the closed control boundary needed by this first
|
||||
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.
|
||||
Branch merge retains its writer-specific row classifier and multi-commit table
|
||||
algorithms, but its authority, recovery, and visibility boundary now use the
|
||||
RFC-022 adapter contract:
|
||||
|
||||
The source snapshot is a captured merge input, not authority that the target
|
||||
manifest CAS can arbitrate. The current process-local source gate is a stronger
|
||||
same-process fence around that capture, including delete/recreate ABA, but the
|
||||
semantic contract is still "merge the captured source commit." A later source
|
||||
advance does not invalidate an otherwise prepared target publish. Claiming
|
||||
"latest source at target publish" would instead require a cross-process source
|
||||
fence held through the target CAS.
|
||||
1. capture source and target as coherent `WriteTxn` snapshots. The target token
|
||||
is `(BranchIdentifier, exact optional graph_head, accepted schema identity)`;
|
||||
the effective lineage head is captured separately because a fresh named
|
||||
branch can inherit a parent while its own `graph_head:<branch>` row is absent;
|
||||
2. compute the merge base from those captured commit ids and classify against
|
||||
the immutable base/source/target snapshots outside table gates;
|
||||
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
|
||||
name underneath a merge plan. The race test deliberately recreates a target with
|
||||
the same name and numeric Lance version but a different `BranchIdentifier`, so
|
||||
version-only checking cannot accidentally satisfy it. This is a process-local
|
||||
bridge, not a cross-process conditional-ref primitive and not a substitute for
|
||||
the later full branch-merge read-set/reprepare adapter. `sync_branch` joins the
|
||||
same root schema gate before replacing a handle's coordinator, so it cannot
|
||||
overwrite merge's temporary target coordinator or change a native control's
|
||||
active-branch authority mid-operation.
|
||||
Publisher retries cannot re-parent the prepared merge onto a newer target. Any
|
||||
failure after the v4 sidecar is durable returns `RecoveryRequired`. Full recovery
|
||||
rolls confirmed effects forward only while the captured target authority still
|
||||
matches; otherwise it compensates the owned effects while preserving the target
|
||||
winner, or fails closed when foreign/interleaved table state makes compensation
|
||||
unverifiable. An Armed first-touch ref with no data HEAD movement is reclaimed
|
||||
without manufacturing rollback lineage. Armed recovery accepts only a
|
||||
contiguous prefix of the pre-minted data chain. Rebuildable `CreateIndex`
|
||||
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
|
||||
|
||||
|
|
@ -334,16 +351,19 @@ are left at `Lance HEAD = manifest_pinned + 1`.
|
|||
post_commit_pin)` it intends to commit + the writer kind +
|
||||
actor_id.
|
||||
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
|
||||
its pre-minted transaction identity.
|
||||
target-ref creation and branch-local `stage_*`; the schema-v3 sidecar already
|
||||
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.
|
||||
- **Phase-B confirmation:** a `BranchMerge` writer
|
||||
advances each table's HEAD by *several* commits (append → upsert →
|
||||
delete), so a bare "HEAD moved" is ambiguous — it could be a complete
|
||||
publish or one crashed mid-sequence. After the whole per-table loop
|
||||
finishes, the writer re-writes the sidecar stamping each pin's
|
||||
`confirmed_version` with the exact achieved version, then proceeds to
|
||||
Phase C. Schema-v3 Mutation/Load sidecars also confirm: each table must
|
||||
- **Phase-B confirmation:** a schema-v4 `BranchMerge` writer
|
||||
advances each table's HEAD by *several* exact commits (append → upsert →
|
||||
delete). Recovery proves a contiguous prefix of the pre-armed transaction
|
||||
chain rather than inferring ownership from numeric HEAD movement. After the
|
||||
whole per-table loop finishes, the writer atomically confirms each exact
|
||||
achieved version, the complete logical manifest delta, and first-touch ref
|
||||
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
|
||||
sidecar records the exact `SubTableUpdate` plus original lineage intent.
|
||||
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
|
||||
decision tree (RolledPastExpected / NoMovement / UnexpectedAtP1 /
|
||||
UnexpectedMultistep / IncompletePhaseB / InvariantViolation). For a
|
||||
`BranchMerge` sidecar, a moved HEAD with no `confirmed_version` classifies
|
||||
as `IncompletePhaseB` (a partial multi-commit publish) and forces roll-back;
|
||||
with a `confirmed_version`, roll-forward targets exactly that version.
|
||||
legacy `BranchMerge` sidecar, a moved HEAD with no `confirmed_version`
|
||||
classifies as `IncompletePhaseB` (a partial multi-commit publish) and forces
|
||||
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
|
||||
Lance transaction identity at the confirmed version, the original immutable
|
||||
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
|
||||
`_graph_commit_recoveries.lance` row records `recovery_kind`,
|
||||
`recovery_for_actor` (the original sidecar's actor), `operation_id`, and
|
||||
exact per-table outcomes. A v3 roll-forward publishes the interrupted
|
||||
writer's fixed lineage intent, including its original actor; rollback and
|
||||
legacy recovery commits use `actor_id = "omnigraph:recovery"`. Ordinary
|
||||
exact per-table outcomes. Schema-v3 Mutation/Load and schema-v4 BranchMerge
|
||||
roll-forward publish the interrupted writer's fixed lineage intent,
|
||||
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
|
||||
CLI currently has no public query for the recovery-audit table.
|
||||
- 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
|
||||
`omnigraph repair`, which refuses while a sidecar is pending.
|
||||
`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
|
||||
recovery barrier.
|
||||
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,
|
||||
load, and schema apply, and its strict preconditions fail loudly on drift.
|
||||
|
||||
For enrolled mutation/load, the publisher rechecks the attempt's exact native
|
||||
branch identity and `graph_head` as well as the touched-table versions. A
|
||||
For enrolled mutation/load and branch merge, the publisher rechecks the
|
||||
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
|
||||
prepared authority instead of silently reparenting it. Before effects, an
|
||||
insert-only mutation or Append/Merge load fully reprepares with a bounded retry; strict
|
||||
Update/Delete/Overwrite returns `ReadSetChanged`; after any effect, any later
|
||||
error returns `RecoveryRequired` and leaves the fixed v3 intent durable. Legacy
|
||||
writers still arbitrate only their explicit touched-table expectations until
|
||||
their adapters are enrolled.
|
||||
Update/Delete/Overwrite and branch merge return `ReadSetChanged`; after any
|
||||
effect, any later error returns `RecoveryRequired` and leaves the fixed v3/v4
|
||||
intent durable. Schema apply and optimize/index remain on their writer-specific
|
||||
arbitration until their adapters are enrolled.
|
||||
|
||||
**Sidecar I/O failure semantics** (all sidecar I/O goes through the
|
||||
backend-generic `StorageAdapter`; the contracts below are pinned by the
|
||||
|
|
|
|||
|
|
@ -694,14 +694,19 @@ Implementation proceeds in this order:
|
|||
> **Implementation note (2026-07-11):** mutation/load now use this coarse
|
||||
> token, schema-v3 exact-effect sidecars, fixed lineage/rollback outcome ids,
|
||||
> zero transparent Lance commit retries, and bounded full reprepare before
|
||||
> effects. Branch merge remains on its writer-specific multi-commit path, but
|
||||
> now holds the root-shared schema plus source/target branch gates from its
|
||||
> strict recovery barrier and authority capture through publication. It plans
|
||||
> with an accepted-contract catalog captured under that schema gate, then
|
||||
> acquires all catalog table gates for source and target, re-lists recovery,
|
||||
> and compares fresh manifest versions before Phase A. This closes
|
||||
> same-process delete/recreate ABA and legacy table-only-writer races while
|
||||
> its full exact-effect adapter remains future work. Schema apply,
|
||||
> effects. Branch merge now captures an immutable source commit/snapshot and
|
||||
> the target coarse token, computes its merge base from those captured ids,
|
||||
> and revalidates target authority plus source incarnation under the ordered
|
||||
> gates. Its schema-v4 recovery envelope distinguishes multi-commit HEAD
|
||||
> effects from first-touch ref-only forks, persists an ordered pre-minted
|
||||
> Lance transaction chain for every logical data effect plus fixed
|
||||
> merge/rollback lineage, and confirms the complete manifest delta (including
|
||||
> 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
|
||||
> their adapter slices land.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| **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`
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
| `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. |
|
||||
| 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). |
|
||||
| Schema apply lock | `__schema_apply_lock__` | schema apply |
|
||||
| Manifest publisher retry budget | `PUBLISHER_RETRY_BUDGET = 5` | manifest publish |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue