mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-07-12 03:12:11 +02:00
Harden branch recovery and cleanup retention (#344)
* Harden branch recovery and cleanup retention * Fail closed on blocked partial rollback
This commit is contained in:
parent
f758ff0d17
commit
bd4c614e42
25 changed files with 2227 additions and 213 deletions
699
crates/omnigraph/src/branch_control.rs
Normal file
699
crates/omnigraph/src/branch_control.rs
Normal file
|
|
@ -0,0 +1,699 @@
|
|||
//! Crash classification for Lance's native branch controls.
|
||||
//!
|
||||
//! Lance branch creation is deliberately two-phase: it first commits a shallow
|
||||
//! clone under `tree/{branch}` and then writes authoritative `BranchContents`.
|
||||
//! Deletion removes `BranchContents` before reclaiming that tree. These helpers
|
||||
//! keep OmniGraph on Lance's public APIs while making both operations bounded
|
||||
//! and idempotent under the documented single-writer-process branch-control
|
||||
//! boundary. Live graph names are path-prefix-disjoint because Lance cannot
|
||||
//! recursively reclaim an ancestor tree while a path-child branch remains.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use lance::Dataset;
|
||||
use lance::dataset::refs::{BranchContents, BranchIdentifier, check_valid_branch};
|
||||
|
||||
use crate::error::{OmniError, Result};
|
||||
|
||||
/// Result of a recoverable native create attempt.
|
||||
pub(crate) enum BranchCreateOutcome {
|
||||
/// The requested branch is durably authoritative and its dataset opens.
|
||||
Created(Dataset),
|
||||
/// The target ref existed before this invocation. Callers classify its
|
||||
/// ownership at their own logical layer; this helper never deletes it.
|
||||
/// A ref that appears after target absence was captured is instead a typed
|
||||
/// authority-change error and cannot enter an orphan-reclaim path.
|
||||
RefAlreadyExists,
|
||||
}
|
||||
|
||||
fn lance_error(error: lance::Error) -> OmniError {
|
||||
OmniError::Lance(error.to_string())
|
||||
}
|
||||
|
||||
/// Lance's local backend reports `NotFound` when the target tree is already
|
||||
/// absent, while object stores commonly treat the empty-prefix delete as a
|
||||
/// success. Normalize both to the idempotent contract every OmniGraph branch
|
||||
/// reconciler needs.
|
||||
pub(crate) async fn force_delete_branch_idempotent(
|
||||
dataset: &mut Dataset,
|
||||
branch: &str,
|
||||
) -> Result<()> {
|
||||
let branches = list_branch_contents(dataset).await?;
|
||||
if let Some(child) = path_descendant(&branches, branch) {
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"cannot reclaim branch '{branch}' while live branch '{child}' shares its physical \
|
||||
Lance path; delete the child branch first"
|
||||
)));
|
||||
}
|
||||
force_delete_branch_tree_unchecked(dataset, branch).await
|
||||
}
|
||||
|
||||
async fn force_delete_branch_tree_unchecked(dataset: &mut Dataset, branch: &str) -> Result<()> {
|
||||
match dataset.force_delete_branch(branch).await {
|
||||
Ok(()) | Err(lance::Error::RefNotFound { .. }) | Err(lance::Error::NotFound { .. }) => {
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(lance_error(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_branch_contents(dataset: &Dataset) -> Result<HashMap<String, BranchContents>> {
|
||||
dataset.list_branches().await.map_err(lance_error)
|
||||
}
|
||||
|
||||
async fn branch_contents(dataset: &Dataset, branch: &str) -> Result<Option<BranchContents>> {
|
||||
Ok(list_branch_contents(dataset).await?.get(branch).cloned())
|
||||
}
|
||||
|
||||
pub(crate) fn path_descendant<'a>(
|
||||
branches: &'a HashMap<String, BranchContents>,
|
||||
branch: &str,
|
||||
) -> Option<&'a str> {
|
||||
let prefix = format!("{branch}/");
|
||||
branches
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.filter(|candidate| candidate.starts_with(&prefix))
|
||||
.min()
|
||||
}
|
||||
|
||||
fn path_collision<'a>(
|
||||
branches: &'a HashMap<String, BranchContents>,
|
||||
branch: &str,
|
||||
) -> Option<&'a str> {
|
||||
let branch_prefix = format!("{branch}/");
|
||||
branches
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.filter(|candidate| {
|
||||
*candidate != branch
|
||||
&& *candidate != "main"
|
||||
&& (candidate.starts_with(&branch_prefix)
|
||||
|| branch.starts_with(&format!("{candidate}/")))
|
||||
})
|
||||
.min()
|
||||
}
|
||||
|
||||
fn encode_identifier(identifier: &BranchIdentifier) -> Result<String> {
|
||||
serde_json::to_string(identifier).map_err(|error| {
|
||||
OmniError::manifest_internal(format!("failed to encode Lance branch identifier: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn authority_appeared_after_absence(dataset: &Dataset, branch: &str) -> Result<OmniError> {
|
||||
match branch_contents(dataset, branch).await? {
|
||||
Some(contents) => Ok(OmniError::manifest_read_set_changed(
|
||||
format!("branch_identifier:{branch}"),
|
||||
None,
|
||||
Some(encode_identifier(&contents.identifier)?),
|
||||
)),
|
||||
None => Ok(OmniError::manifest_conflict(format!(
|
||||
"branch authority for '{branch}' changed during absent-only reclaim; refusing \
|
||||
destructive cleanup"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reclaim only when the authoritative ref is freshly absent. Returns `false`
|
||||
/// if a ref exists and therefore nothing was touched. A live path-child is a
|
||||
/// conflict because Lance deliberately keeps the ancestor directory in that
|
||||
/// state; the graph namespace prevents new instances of that overlap.
|
||||
pub(crate) async fn reclaim_ref_absent_tree(dataset: &mut Dataset, branch: &str) -> Result<bool> {
|
||||
let branches = list_branch_contents(dataset).await?;
|
||||
if branches.contains_key(branch) {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(child) = path_descendant(&branches, branch) {
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"cannot reclaim absent branch '{branch}' while live branch '{child}' shares its \
|
||||
physical Lance path; delete the child branch first"
|
||||
)));
|
||||
}
|
||||
// This is the final authority read before the destructive call. The
|
||||
// supported single-writer-process gate boundary prevents a local create
|
||||
// from interleaving after it; an exact ref observed here is never passed to
|
||||
// Lance's broad force-delete primitive.
|
||||
force_delete_branch_tree_unchecked(dataset, branch).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// A completed create receives an identifier equal to the parent's complete
|
||||
/// identifier mapping plus one newly-generated `(parent_version, uuid)` entry.
|
||||
/// The UUID itself cannot be pre-minted through Lance's public API, so this is
|
||||
/// the strongest completion proof available inside the supported
|
||||
/// single-writer-process boundary.
|
||||
fn matches_create_expectation(
|
||||
contents: &BranchContents,
|
||||
parent_branch: Option<&str>,
|
||||
parent_version: u64,
|
||||
parent_identifier: &BranchIdentifier,
|
||||
) -> bool {
|
||||
let expected_parent = parent_branch.filter(|branch| *branch != "main");
|
||||
let mapping = &contents.identifier.version_mapping;
|
||||
let parent_mapping = &parent_identifier.version_mapping;
|
||||
contents.parent_branch.as_deref() == expected_parent
|
||||
&& contents.parent_version == parent_version
|
||||
&& mapping.len() == parent_mapping.len() + 1
|
||||
&& mapping.starts_with(parent_mapping)
|
||||
&& mapping
|
||||
.last()
|
||||
.is_some_and(|(version, uuid)| *version == parent_version && !uuid.is_empty())
|
||||
}
|
||||
|
||||
/// Create a branch with a bounded recovery classifier.
|
||||
///
|
||||
/// The caller must already hold OmniGraph's schema → source/target branch →
|
||||
/// table gate envelope. An absent `BranchContents` ref makes any same-name tree
|
||||
/// derived garbage, so it is reclaimed before the first attempt. An ambiguous
|
||||
/// native error is then classified from fresh authority: matching contents are
|
||||
/// accepted as a lost acknowledgement, mismatching contents are never deleted,
|
||||
/// and a ref-less clone is reclaimed before one bounded retry.
|
||||
pub(crate) async fn create_branch_recoverably(
|
||||
source: &mut Dataset,
|
||||
branch: &str,
|
||||
source_version: u64,
|
||||
) -> Result<BranchCreateOutcome> {
|
||||
// Lance validates inside phase 2 today. Validate before phase 1 so an
|
||||
// invalid name cannot leave a clone-only zombie.
|
||||
check_valid_branch(branch).map_err(lance_error)?;
|
||||
if source.version().version != source_version {
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"branch source moved before native create: expected version {}, current {}",
|
||||
source_version,
|
||||
source.version().version
|
||||
)));
|
||||
}
|
||||
|
||||
let parent_branch = source.manifest().branch.clone();
|
||||
let parent_identifier = source.branch_identifier().await.map_err(lance_error)?;
|
||||
|
||||
let initial_branches = list_branch_contents(source).await?;
|
||||
if initial_branches.contains_key(branch) {
|
||||
return Ok(BranchCreateOutcome::RefAlreadyExists);
|
||||
}
|
||||
if let Some(conflicting) = path_collision(&initial_branches, branch) {
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"cannot create branch '{branch}' while live branch '{conflicting}' shares its \
|
||||
physical Lance path; live graph branch names may not be ancestors or descendants"
|
||||
)));
|
||||
}
|
||||
if !reclaim_ref_absent_tree(source, branch).await? {
|
||||
return Err(authority_appeared_after_absence(source, branch).await?);
|
||||
}
|
||||
|
||||
for attempt in 0..2 {
|
||||
let native_error = match source
|
||||
.create_branch(branch, source_version, None)
|
||||
.await
|
||||
.map_err(lance_error)
|
||||
{
|
||||
Ok(created) => match crate::failpoints::maybe_fail(
|
||||
crate::failpoints::names::BRANCH_CREATE_POST_NATIVE,
|
||||
) {
|
||||
Ok(()) => return Ok(BranchCreateOutcome::Created(created)),
|
||||
Err(error) => error,
|
||||
},
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
let observed = branch_contents(source, branch).await.map_err(|classifier_error| {
|
||||
OmniError::manifest_internal(format!(
|
||||
"native create of branch '{branch}' returned an ambiguous error ({native_error}); \
|
||||
reading BranchContents to classify it also failed ({classifier_error})"
|
||||
))
|
||||
})?;
|
||||
if let Some(contents) = observed {
|
||||
if !matches_create_expectation(
|
||||
&contents,
|
||||
parent_branch.as_deref(),
|
||||
source_version,
|
||||
&parent_identifier,
|
||||
) {
|
||||
return Err(OmniError::manifest_read_set_changed(
|
||||
format!("branch_identifier:{branch}"),
|
||||
None,
|
||||
Some(encode_identifier(&contents.identifier)?),
|
||||
));
|
||||
}
|
||||
let created = source.checkout_branch(branch).await.map_err(|error| {
|
||||
OmniError::manifest_internal(format!(
|
||||
"branch '{}' has matching authoritative metadata after an ambiguous create, \
|
||||
but its branch dataset cannot be opened: {}; original native error: {}",
|
||||
branch, error, native_error
|
||||
))
|
||||
})?;
|
||||
return Ok(BranchCreateOutcome::Created(created));
|
||||
}
|
||||
|
||||
match reclaim_ref_absent_tree(source, branch).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return Err(authority_appeared_after_absence(source, branch).await?);
|
||||
}
|
||||
Err(cleanup_error) => {
|
||||
return Err(OmniError::manifest_internal(format!(
|
||||
"native create of branch '{}' failed before authoritative metadata was visible \
|
||||
({native_error}); clone-only cleanup also failed ({cleanup_error})",
|
||||
branch
|
||||
)));
|
||||
}
|
||||
}
|
||||
if attempt == 1 {
|
||||
return Err(native_error);
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!("bounded native branch-create loop returns from every attempt")
|
||||
}
|
||||
|
||||
/// Delete a branch and classify Lance's authority-removal → tree-cleanup gap.
|
||||
///
|
||||
/// A missing ref after an error is logical success. Tree reclamation is derived
|
||||
/// state and is retried best-effort; a recreated identifier is never deleted.
|
||||
pub(crate) async fn delete_branch_recoverably(
|
||||
dataset: &mut Dataset,
|
||||
branch: &str,
|
||||
expected_identifier: &BranchIdentifier,
|
||||
) -> Result<()> {
|
||||
let initial = list_branch_contents(dataset).await?;
|
||||
let Some(initial_contents) = initial.get(branch) else {
|
||||
match reclaim_ref_absent_tree(dataset, branch).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return Err(authority_appeared_after_absence(dataset, branch).await?);
|
||||
}
|
||||
Err(cleanup_error) => {
|
||||
tracing::warn!(
|
||||
target: "omnigraph::branch_control",
|
||||
branch,
|
||||
error = %cleanup_error,
|
||||
"branch authority is already deleted; derived tree reclaim remains pending",
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
if initial_contents.identifier != *expected_identifier {
|
||||
return Err(OmniError::manifest_read_set_changed(
|
||||
format!("branch_identifier:{branch}"),
|
||||
Some(encode_identifier(expected_identifier)?),
|
||||
Some(encode_identifier(&initial_contents.identifier)?),
|
||||
));
|
||||
}
|
||||
if let Some(child) = path_descendant(&initial, branch) {
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"cannot delete branch '{branch}' while live branch '{child}' shares its physical \
|
||||
Lance path; delete the child branch first"
|
||||
)));
|
||||
}
|
||||
|
||||
let native_result = match dataset.delete_branch(branch).await.map_err(lance_error) {
|
||||
Ok(()) => {
|
||||
crate::failpoints::maybe_fail(crate::failpoints::names::BRANCH_DELETE_POST_NATIVE)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let native_error = match native_result {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
let observed = branch_contents(dataset, branch)
|
||||
.await
|
||||
.map_err(|classifier_error| {
|
||||
OmniError::manifest_internal(format!(
|
||||
"native delete of branch '{branch}' returned an ambiguous error ({native_error}); \
|
||||
reading BranchContents to classify it also failed ({classifier_error})"
|
||||
))
|
||||
})?;
|
||||
match observed {
|
||||
None => {
|
||||
match reclaim_ref_absent_tree(dataset, branch).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return Err(authority_appeared_after_absence(dataset, branch).await?);
|
||||
}
|
||||
Err(cleanup_error) => {
|
||||
tracing::warn!(
|
||||
target: "omnigraph::branch_control",
|
||||
branch,
|
||||
error = %cleanup_error,
|
||||
"branch authority is deleted; derived tree reclaim remains pending",
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some(contents) if contents.identifier == *expected_identifier => Err(native_error),
|
||||
Some(contents) => Err(OmniError::manifest_read_set_changed(
|
||||
format!("branch_identifier:{branch}"),
|
||||
Some(encode_identifier(expected_identifier)?),
|
||||
Some(encode_identifier(&contents.identifier)?),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use arrow_array::{Int32Array, RecordBatch, RecordBatchIterator};
|
||||
use arrow_schema::{DataType, Field, Schema};
|
||||
use lance::dataset::{WriteMode, WriteParams};
|
||||
|
||||
async fn test_dataset(dir: &tempfile::TempDir) -> Dataset {
|
||||
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
|
||||
let batch = RecordBatch::try_new(
|
||||
Arc::clone(&schema),
|
||||
vec![Arc::new(Int32Array::from(vec![1]))],
|
||||
)
|
||||
.unwrap();
|
||||
let reader = RecordBatchIterator::new(vec![Ok(batch)], schema);
|
||||
// forbidden-api-allow: test-only raw Lance fixture for native branch-control truth cells
|
||||
Dataset::write(
|
||||
reader,
|
||||
dir.path().to_str().unwrap(),
|
||||
Some(WriteParams {
|
||||
mode: WriteMode::Create,
|
||||
auto_cleanup: None,
|
||||
skip_auto_cleanup: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_match_requires_exact_parent_incarnation_prefix() {
|
||||
let parent = BranchIdentifier {
|
||||
version_mapping: vec![(2, "parent-a".to_string())],
|
||||
};
|
||||
let matching = BranchContents {
|
||||
parent_branch: Some("source".to_string()),
|
||||
identifier: BranchIdentifier {
|
||||
version_mapping: vec![(2, "parent-a".to_string()), (7, "child".to_string())],
|
||||
},
|
||||
parent_version: 7,
|
||||
create_at: 0,
|
||||
manifest_size: 0,
|
||||
metadata: Default::default(),
|
||||
};
|
||||
assert!(matches_create_expectation(
|
||||
&matching,
|
||||
Some("source"),
|
||||
7,
|
||||
&parent
|
||||
));
|
||||
|
||||
let foreign_parent = BranchIdentifier {
|
||||
version_mapping: vec![(2, "parent-b".to_string())],
|
||||
};
|
||||
assert!(!matches_create_expectation(
|
||||
&matching,
|
||||
Some("source"),
|
||||
7,
|
||||
&foreign_parent
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_accepts_absent_authority_and_reclaims_remaining_tree() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut dataset = test_dataset(&dir).await;
|
||||
let version = dataset.version().version;
|
||||
dataset
|
||||
.create_branch("feature", version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let identifier = dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.get("feature")
|
||||
.unwrap()
|
||||
.identifier
|
||||
.clone();
|
||||
std::fs::remove_file(
|
||||
dir.path()
|
||||
.join("_refs")
|
||||
.join("branches")
|
||||
.join("feature.json"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
delete_branch_recoverably(&mut dataset, "feature", &identifier)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!dir.path().join("tree").join("feature").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_preserves_native_error_while_same_identifier_remains() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut dataset = test_dataset(&dir).await;
|
||||
let version = dataset.version().version;
|
||||
let mut feature = dataset
|
||||
.create_branch("feature", version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let identifier = dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.get("feature")
|
||||
.unwrap()
|
||||
.identifier
|
||||
.clone();
|
||||
let feature_version = feature.version().version;
|
||||
feature
|
||||
.create_branch("dependent", feature_version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = delete_branch_recoverably(&mut dataset, "feature", &identifier)
|
||||
.await
|
||||
.expect_err("a lineage-dependent ref must preserve Lance's delete refusal");
|
||||
assert!(error.to_string().contains("referenc"));
|
||||
assert_eq!(
|
||||
dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.get("feature")
|
||||
.unwrap()
|
||||
.identifier,
|
||||
identifier
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_rejects_recreated_identifier_without_removing_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut dataset = test_dataset(&dir).await;
|
||||
let version = dataset.version().version;
|
||||
dataset
|
||||
.create_branch("feature", version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let original = dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.get("feature")
|
||||
.unwrap()
|
||||
.identifier
|
||||
.clone();
|
||||
dataset.delete_branch("feature").await.unwrap();
|
||||
dataset
|
||||
.create_branch("feature", version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let recreated = dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.get("feature")
|
||||
.unwrap()
|
||||
.identifier
|
||||
.clone();
|
||||
assert_ne!(original, recreated);
|
||||
|
||||
let error = delete_branch_recoverably(&mut dataset, "feature", &original)
|
||||
.await
|
||||
.expect_err("a recreated target must be fenced by its identifier");
|
||||
let OmniError::Manifest(error) = error else {
|
||||
panic!("expected typed manifest conflict");
|
||||
};
|
||||
match error.details {
|
||||
Some(crate::error::ManifestConflictDetails::ReadSetChanged {
|
||||
member,
|
||||
expected: Some(expected),
|
||||
actual: Some(actual),
|
||||
}) => {
|
||||
assert_eq!(member, "branch_identifier:feature");
|
||||
assert_eq!(expected, serde_json::to_string(&original).unwrap());
|
||||
assert_eq!(actual, serde_json::to_string(&recreated).unwrap());
|
||||
}
|
||||
other => panic!("expected identifier ReadSetChanged, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.get("feature")
|
||||
.unwrap()
|
||||
.identifier,
|
||||
recreated,
|
||||
"classifier must never delete the recreated authority"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lower_create_chokepoint_rejects_prefix_collision_before_clone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut dataset = test_dataset(&dir).await;
|
||||
let version = dataset.version().version;
|
||||
dataset
|
||||
.create_branch("feature", version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = match create_branch_recoverably(&mut dataset, "feature/child", version).await {
|
||||
Ok(_) => panic!("the lower branch-control surface must enforce prefix disjointness"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.to_string().contains("feature"));
|
||||
assert!(error.to_string().contains("physical Lance path"));
|
||||
assert!(
|
||||
!dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains_key("feature/child"),
|
||||
"admission refusal must precede authoritative ref creation"
|
||||
);
|
||||
assert!(
|
||||
!dir.path()
|
||||
.join("tree")
|
||||
.join("feature")
|
||||
.join("child")
|
||||
.exists(),
|
||||
"admission refusal must precede Lance's shallow-clone phase"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lower_create_preserves_preexisting_same_name_ref() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut dataset = test_dataset(&dir).await;
|
||||
let version = dataset.version().version;
|
||||
dataset
|
||||
.create_branch("feature", version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let before = dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.get("feature")
|
||||
.unwrap()
|
||||
.identifier
|
||||
.clone();
|
||||
|
||||
let outcome = create_branch_recoverably(&mut dataset, "feature", version)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, BranchCreateOutcome::RefAlreadyExists));
|
||||
let after = dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.get("feature")
|
||||
.unwrap()
|
||||
.identifier
|
||||
.clone();
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"classification must not replace the live ref"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_only_reclaim_preserves_authority_that_appeared_after_prior_absence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut dataset = test_dataset(&dir).await;
|
||||
let version = dataset.version().version;
|
||||
assert!(
|
||||
!dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains_key("feature"),
|
||||
"precondition: an earlier classifier observed target absence"
|
||||
);
|
||||
dataset
|
||||
.create_branch("feature", version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let before = dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.get("feature")
|
||||
.unwrap()
|
||||
.identifier
|
||||
.clone();
|
||||
|
||||
assert!(
|
||||
!reclaim_ref_absent_tree(&mut dataset, "feature")
|
||||
.await
|
||||
.unwrap(),
|
||||
"the final authority check must refuse destructive cleanup"
|
||||
);
|
||||
assert_eq!(
|
||||
dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.get("feature")
|
||||
.unwrap()
|
||||
.identifier,
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_reclaim_reports_lexically_first_live_physical_path_child() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut dataset = test_dataset(&dir).await;
|
||||
let version = dataset.version().version;
|
||||
dataset
|
||||
.create_branch("feature/zeta", version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
dataset
|
||||
.create_branch("feature/alpha", version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
dataset
|
||||
.create_branch("feature", version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = force_delete_branch_idempotent(&mut dataset, "feature")
|
||||
.await
|
||||
.expect_err("ancestor reclaim must not report success while a path-child is live");
|
||||
assert!(error.to_string().contains("feature/alpha"));
|
||||
assert!(
|
||||
dataset
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains_key("feature"),
|
||||
"preflight refusal must preserve ancestor authority"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -238,10 +238,10 @@ impl GraphCoordinator {
|
|||
let branch = normalize_branch_name(name)?
|
||||
.ok_or_else(|| OmniError::manifest("cannot create branch 'main'".to_string()))?;
|
||||
|
||||
// Manifest is the single branch authority (it forks `__manifest` first).
|
||||
// The commit graph is a pure `__manifest` projection (Phase B), so a
|
||||
// branch create is one atomic manifest op — no derived commit-graph
|
||||
// branch to fork, and nothing to roll back.
|
||||
// Manifest BranchContents is the single branch authority. Lance creates
|
||||
// it in two physical phases (shallow clone, then BranchContents); the
|
||||
// manifest coordinator classifies/reclaims a clone-only zombie before
|
||||
// a bounded retry. No graph-lineage branch is created or rolled back.
|
||||
self.manifest.create_branch(&branch).await
|
||||
}
|
||||
|
||||
|
|
@ -255,10 +255,11 @@ impl GraphCoordinator {
|
|||
)));
|
||||
}
|
||||
|
||||
// Manifest is the single branch authority (Phase B): one atomic op makes
|
||||
// the branch cease to exist. The commit graph is a pure `__manifest`
|
||||
// projection with no derived branch to reclaim; the per-table data forks
|
||||
// are reclaimed by `cleanup`, not here.
|
||||
// Removing manifest BranchContents is the logical visibility point.
|
||||
// Lance reclaims the branch tree afterward, so an error may still mean
|
||||
// logical deletion succeeded; the manifest coordinator reclassifies
|
||||
// that outcome from fresh authority. Per-table data forks remain
|
||||
// derived state and are reclaimed by the engine afterward.
|
||||
self.manifest.delete_branch(&branch).await
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -634,10 +634,14 @@ impl ManifestCoordinator {
|
|||
|
||||
pub async fn create_branch(&mut self, name: &str) -> Result<()> {
|
||||
let mut ds = self.dataset.clone();
|
||||
ds.create_branch(name, self.version(), None)
|
||||
.await
|
||||
.map_err(|e| OmniError::Lance(e.to_string()))?;
|
||||
Ok(())
|
||||
match crate::branch_control::create_branch_recoverably(&mut ds, name, self.version())
|
||||
.await?
|
||||
{
|
||||
crate::branch_control::BranchCreateOutcome::Created(_) => Ok(()),
|
||||
crate::branch_control::BranchCreateOutcome::RefAlreadyExists => Err(
|
||||
OmniError::manifest_conflict(format!("branch '{}' already exists", name)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_branch(&mut self, name: &str) -> Result<()> {
|
||||
|
|
@ -649,9 +653,17 @@ impl ManifestCoordinator {
|
|||
crate::instrumentation::manifest_wrapper(),
|
||||
)
|
||||
.await?;
|
||||
ds.delete_branch(name)
|
||||
let branches = ds
|
||||
.list_branches()
|
||||
.await
|
||||
.map_err(|e| OmniError::Lance(e.to_string()))?;
|
||||
.map_err(|error| OmniError::Lance(error.to_string()))?;
|
||||
let expected_identifier = branches
|
||||
.get(name)
|
||||
.ok_or_else(|| OmniError::manifest_not_found(format!("branch '{}' not found", name)))?
|
||||
.identifier
|
||||
.clone();
|
||||
crate::branch_control::delete_branch_recoverably(&mut ds, name, &expected_identifier)
|
||||
.await?;
|
||||
self.dataset = open_manifest_dataset(&self.root_uri, self.active_branch.as_deref()).await?;
|
||||
self.known_state = read_manifest_state(&self.dataset).await?;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -1817,8 +1817,28 @@ async fn process_sidecar(
|
|||
return Ok(false);
|
||||
}
|
||||
if matches!(mode, RecoveryMode::Full) {
|
||||
cleanup_unpublished_no_effect_forks(root_uri, storage.as_ref(), sidecar, &states)
|
||||
.await?;
|
||||
if let NoEffectForkCleanup::DeferredPathChild {
|
||||
table_path,
|
||||
target_branch,
|
||||
path_child,
|
||||
} = cleanup_unpublished_no_effect_forks(
|
||||
root_uri,
|
||||
storage.as_ref(),
|
||||
sidecar,
|
||||
&states,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
warn!(
|
||||
operation_id = sidecar.operation_id.as_str(),
|
||||
table_path,
|
||||
branch = target_branch,
|
||||
path_child,
|
||||
"recovery: deferring no-effect fork cleanup until legacy path-child \
|
||||
branches are deleted leaf-first"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
warn!(
|
||||
operation_id = sidecar.operation_id.as_str(),
|
||||
|
|
@ -2369,6 +2389,16 @@ enum EffectOwnership {
|
|||
Unverifiable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum NoEffectForkCleanup {
|
||||
Complete,
|
||||
DeferredPathChild {
|
||||
table_path: String,
|
||||
target_branch: String,
|
||||
path_child: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Remove first-touch named-branch refs created by an Armed v3 attempt that
|
||||
/// never landed this table's planned transaction.
|
||||
///
|
||||
|
|
@ -2386,9 +2416,9 @@ async fn cleanup_unpublished_no_effect_forks(
|
|||
storage: &dyn StorageAdapter,
|
||||
sidecar: &RecoverySidecar,
|
||||
states: &[ClassifiedTable],
|
||||
) -> Result<()> {
|
||||
) -> Result<NoEffectForkCleanup> {
|
||||
if sidecar.protocol_v3.is_none() {
|
||||
return Ok(());
|
||||
return Ok(NoEffectForkCleanup::Complete);
|
||||
}
|
||||
|
||||
let all_sidecars = list_sidecars(root_uri, storage).await?;
|
||||
|
|
@ -2432,9 +2462,38 @@ async fn cleanup_unpublished_no_effect_forks(
|
|||
.list_branches()
|
||||
.await
|
||||
.map_err(|error| OmniError::Lance(error.to_string()))?;
|
||||
if let Some(child) = crate::branch_control::path_descendant(&branches, target_branch) {
|
||||
// Lance cannot reclaim an ancestor tree while a slash-separated
|
||||
// path-child remains. Old stores could admit that namespace shape.
|
||||
// Keep the ownership sidecar and let open complete so the operator
|
||||
// can delete the child branch first; the next Full sweep then
|
||||
// rechecks authority and reclaims the ancestor fork.
|
||||
warn!(
|
||||
operation_id = sidecar.operation_id.as_str(),
|
||||
table_path = pin.table_path.as_str(),
|
||||
branch = target_branch,
|
||||
path_child = child,
|
||||
"recovery: deferring unpublished fork cleanup for legacy path overlap"
|
||||
);
|
||||
return Ok(NoEffectForkCleanup::DeferredPathChild {
|
||||
table_path: pin.table_path.clone(),
|
||||
target_branch: target_branch.to_string(),
|
||||
path_child: child.to_string(),
|
||||
});
|
||||
}
|
||||
let Some(contents) = branches.get(target_branch) else {
|
||||
// Crash-before-fork, or a previous cleanup pass that crashed before
|
||||
// its rollback publish. Both are already converged physically.
|
||||
// BranchContents is authoritative, but Lance create writes the
|
||||
// shallow-cloned target dataset first. The absent-ref state is
|
||||
// therefore either crash-before-fork (idempotent no-op) or an
|
||||
// exact clone-only zombie owned by this already-armed sidecar.
|
||||
// Reclaim both through Lance's force API before retiring intent;
|
||||
// merely skipping here leaves the zombie blocking every retry.
|
||||
if !crate::branch_control::reclaim_ref_absent_tree(&mut dataset, target_branch).await? {
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"target ref '{target_branch}' appeared during no-effect recovery; refusing \
|
||||
to retire its ownership intent"
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if contents.parent_version != pin.expected_version {
|
||||
|
|
@ -2468,7 +2527,7 @@ async fn cleanup_unpublished_no_effect_forks(
|
|||
.await
|
||||
.map_err(|error| OmniError::Lance(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(NoEffectForkCleanup::Complete)
|
||||
}
|
||||
|
||||
fn table_requires_rollback_effect(state: &ClassifiedTable) -> bool {
|
||||
|
|
@ -2546,9 +2605,33 @@ async fn roll_back_sidecar(
|
|||
sidecar: &RecoverySidecar,
|
||||
states: &[ClassifiedTable],
|
||||
) -> Result<()> {
|
||||
// An Armed multi-table attempt can create every first-touch ref and then
|
||||
// land effects on only a subset. No-effect refs are not selected by the
|
||||
// rollback manifest publish, so they must be removed BEFORE that publish.
|
||||
// If recovery crashed after publishing first, the fixed rollback outcome
|
||||
// would make the next pass finalize/delete the sidecar without ever seeing
|
||||
// the still-orphaned refs. A rollback that owns any physical effect may not
|
||||
// defer and let read-write open succeed: legacy writers are not all enrolled
|
||||
// in the v3 preparation barrier. Fail closed until the path child is removed.
|
||||
if let NoEffectForkCleanup::DeferredPathChild {
|
||||
table_path,
|
||||
target_branch,
|
||||
path_child,
|
||||
} = cleanup_unpublished_no_effect_forks(root_uri, storage, sidecar, states).await?
|
||||
{
|
||||
return Err(OmniError::manifest_internal(format!(
|
||||
"OCC recovery sidecar '{}' owns physical effects but cannot clean unpublished fork \
|
||||
'{}:{}' while legacy path-child '{}' is live; refusing read-write open; delete the \
|
||||
child branch leaf-first using an existing handle or an offline Lance-level branch \
|
||||
tool, then reopen",
|
||||
sidecar.operation_id, table_path, target_branch, path_child
|
||||
)));
|
||||
}
|
||||
|
||||
// Once the fixed rollback commit is visible, early recovery finalization no
|
||||
// longer has pre-restore table observations. Persist the exact audit plan
|
||||
// first so that path can replay it without fabricating outcomes from pins.
|
||||
// after fork cleanup and before the first restore so that path can replay it
|
||||
// without fabricating outcomes from pins.
|
||||
let prepared_v3 = if sidecar.protocol_v3.is_some() {
|
||||
Some(prepare_v3_rollback_audit_plan(root_uri, storage, sidecar, states).await?)
|
||||
} else {
|
||||
|
|
@ -2556,14 +2639,6 @@ async fn roll_back_sidecar(
|
|||
};
|
||||
let sidecar = prepared_v3.as_ref().unwrap_or(sidecar);
|
||||
|
||||
// An Armed multi-table attempt can create every first-touch ref and then
|
||||
// land effects on only a subset. No-effect refs are not selected by the
|
||||
// rollback manifest publish, so they must be removed BEFORE that publish.
|
||||
// If recovery crashed after publishing first, the fixed rollback outcome
|
||||
// would make the next pass finalize/delete the sidecar without ever seeing
|
||||
// the still-orphaned refs.
|
||||
cleanup_unpublished_no_effect_forks(root_uri, storage, sidecar, states).await?;
|
||||
|
||||
// Restore every drifted table (RolledPastExpected / UnexpectedAtP1 /
|
||||
// UnexpectedMultistep) to its manifest-pinned content, then PUBLISH so
|
||||
// `manifest == Lance HEAD` for each — symmetric with roll-forward. The
|
||||
|
|
|
|||
|
|
@ -1825,7 +1825,41 @@ impl Omnigraph {
|
|||
queue_keys
|
||||
}
|
||||
|
||||
fn ensure_branch_create_namespace_safe(target: &str, branches: &[String]) -> Result<()> {
|
||||
if branches.iter().any(|candidate| candidate == target) {
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"branch '{}' already exists",
|
||||
target
|
||||
)));
|
||||
}
|
||||
|
||||
let target_prefix = format!("{target}/");
|
||||
if let Some(conflicting) = branches.iter().find(|candidate| {
|
||||
candidate.as_str() != "main"
|
||||
&& (candidate.starts_with(&target_prefix)
|
||||
|| target.starts_with(&format!("{candidate}/")))
|
||||
}) {
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"cannot create branch '{target}' while live branch '{conflicting}' shares its \
|
||||
physical Lance path; live graph branch names may not be ancestors or descendants"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_branch_delete_safe(&self, branch: &str, branches: &[String]) -> Result<()> {
|
||||
let path_prefix = format!("{branch}/");
|
||||
if let Some(child) = branches
|
||||
.iter()
|
||||
.find(|candidate| candidate.starts_with(&path_prefix))
|
||||
{
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"cannot delete branch '{branch}' while live branch '{child}' shares its physical \
|
||||
Lance path; delete the child branch first"
|
||||
)));
|
||||
}
|
||||
|
||||
let descendants = self
|
||||
.coordinator
|
||||
.read()
|
||||
|
|
@ -1921,7 +1955,9 @@ impl Omnigraph {
|
|||
.map(|entry| (entry.table_key.clone(), entry.table_path.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Authority flip (+ best-effort commit-graph reclaim) — must succeed.
|
||||
// Authority removal is the logical branch deletion. Lance tree cleanup
|
||||
// follows that ref removal; the coordinator classifies an absent ref as
|
||||
// success even if the physical cleanup acknowledgement failed.
|
||||
self.coordinator.write().await.branch_delete(branch).await?;
|
||||
// Best-effort per-table fork reclaim; cleanup reconciles any leftover.
|
||||
self.cleanup_deleted_branch_tables(branch, &owned_tables)
|
||||
|
|
@ -1998,20 +2034,8 @@ impl Omnigraph {
|
|||
self.refresh_coordinator_only().await?;
|
||||
self.ensure_schema_apply_not_locked("branch_create").await?;
|
||||
self.ensure_schema_state_valid().await?;
|
||||
if self
|
||||
.coordinator
|
||||
.read()
|
||||
.await
|
||||
.all_branches()
|
||||
.await?
|
||||
.iter()
|
||||
.any(|branch| branch == &target)
|
||||
{
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"branch '{}' already exists",
|
||||
target
|
||||
)));
|
||||
}
|
||||
let branches = self.coordinator.read().await.all_branches().await?;
|
||||
Self::ensure_branch_create_namespace_safe(&target, &branches)?;
|
||||
self.coordinator.write().await.branch_create(name).await
|
||||
}
|
||||
|
||||
|
|
@ -2107,20 +2131,8 @@ impl Omnigraph {
|
|||
self.ensure_schema_apply_not_locked("branch_create_from")
|
||||
.await?;
|
||||
self.ensure_schema_state_valid().await?;
|
||||
if self
|
||||
.coordinator
|
||||
.read()
|
||||
.await
|
||||
.all_branches()
|
||||
.await?
|
||||
.iter()
|
||||
.any(|candidate| candidate == &target_branch)
|
||||
{
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"branch '{}' already exists",
|
||||
target_branch
|
||||
)));
|
||||
}
|
||||
let branches = self.coordinator.read().await.all_branches().await?;
|
||||
Self::ensure_branch_create_namespace_safe(&target_branch, &branches)?;
|
||||
// Operate on a freshly-opened source coordinator that's owned locally
|
||||
// — never touch `self.coordinator`. The pre-fix implementation used
|
||||
// `swap_coordinator_for_branch` + operate + `restore_coordinator` as
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@
|
|||
//! older manifest versions until `cleanup` runs.
|
||||
//! * `cleanup_all_tables` — Lance `cleanup_old_versions` on every table.
|
||||
//! Removes manifests (and their unique fragments) older than the configured
|
||||
//! retention. Destructive to version history — callers should gate this
|
||||
//! behind an explicit confirm flag at the CLI layer.
|
||||
//! retention, capped at the oldest main-table version inherited by any live
|
||||
//! lazy graph branch. Destructive to unreferenced version history — callers
|
||||
//! should gate this behind an explicit confirm flag at the CLI layer.
|
||||
//!
|
||||
//! Both orchestrate the graph's node + edge datasets from main authority;
|
||||
//! cleanup preserves Lance-referenced named-branch history according to its
|
||||
//! retention policy.
|
||||
//! cleanup preserves both Lance-referenced native branch history and the
|
||||
//! graph-level lazy-branch references Lance cannot observe.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -786,7 +787,8 @@ async fn compact_internal_table(
|
|||
|
||||
/// Run Lance `cleanup_old_versions` on every node + edge table on `main`,
|
||||
/// using [`CleanupPolicyOptions`]. The latest manifest is always preserved
|
||||
/// regardless (Lance invariant).
|
||||
/// regardless (Lance invariant), and the requested cutoff is capped at the
|
||||
/// oldest main-table version inherited by a live lazy graph branch.
|
||||
pub async fn cleanup_all_tables(
|
||||
db: &mut Omnigraph,
|
||||
options: CleanupPolicyOptions,
|
||||
|
|
@ -896,8 +898,77 @@ pub async fn cleanup_all_tables(
|
|||
));
|
||||
}
|
||||
|
||||
// Lance protects versions referenced by its own per-dataset branches, but
|
||||
// an OmniGraph branch is lazy: until a table is first written on that
|
||||
// branch its manifest entry points directly at an older MAIN version and
|
||||
// no Lance branch ref exists on the data table. Resolve every live graph
|
||||
// branch from fresh authority while schema + all branch/table gates are
|
||||
// held, then cap each main dataset's GC cutoff at its oldest such pin.
|
||||
// Main itself participates: its manifest-visible version must open and
|
||||
// equal Lance HEAD, so uncovered drift is repaired before cleanup rather
|
||||
// than letting HEAD-based GC collect graph-visible authority.
|
||||
// Any branch snapshot read failure aborts before the first table GC: an
|
||||
// unknown live reference is never evidence that a version is disposable.
|
||||
let mut oldest_live_main_version_by_path = std::collections::HashMap::<String, u64>::new();
|
||||
for branch_target in &graph_branches {
|
||||
if branch_target
|
||||
.as_deref()
|
||||
.is_some_and(crate::db::is_internal_system_branch)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let branch_label = branch_target.as_deref().unwrap_or("main");
|
||||
let branch_snapshot = db
|
||||
.fresh_snapshot_for_branch(branch_target.as_deref())
|
||||
.await
|
||||
.map_err(|err| {
|
||||
OmniError::manifest_conflict(format!(
|
||||
"cleanup could not classify live branch '{branch_label}'; refusing version GC: {err}"
|
||||
))
|
||||
})?;
|
||||
for entry in branch_snapshot
|
||||
.entries()
|
||||
.filter(|entry| entry.table_branch.is_none())
|
||||
{
|
||||
// Validate that the exact protected version is still openable
|
||||
// before GC starts. This catches pre-existing damage from an older
|
||||
// cleanup implementation and keeps the sweep fail-closed instead
|
||||
// of deleting unrelated history around an already-broken branch.
|
||||
entry.open(db.root_uri(), None).await.map_err(|err| {
|
||||
OmniError::manifest_conflict(format!(
|
||||
"cleanup could not classify live branch '{branch_label}' table '{}' at main version {}; refusing version GC: {err}",
|
||||
entry.table_key, entry.table_version
|
||||
))
|
||||
})?;
|
||||
let full_path = format!("{}/{}", db.root_uri, entry.table_path);
|
||||
if branch_target.is_none() {
|
||||
let head = db.storage().open_dataset_head(&full_path, None).await?;
|
||||
if head.version() != entry.table_version {
|
||||
return Err(OmniError::manifest_conflict(format!(
|
||||
"cleanup found uncovered HEAD drift for table '{}': manifest version {}, \
|
||||
Lance HEAD {}; run `omnigraph repair` before version GC",
|
||||
entry.table_key,
|
||||
entry.table_version,
|
||||
head.version()
|
||||
)));
|
||||
}
|
||||
}
|
||||
oldest_live_main_version_by_path
|
||||
.entry(full_path)
|
||||
.and_modify(|oldest| *oldest = (*oldest).min(entry.table_version))
|
||||
.or_insert(entry.table_version);
|
||||
}
|
||||
}
|
||||
|
||||
let before_timestamp = options.older_than.map(|d| Utc::now() - d);
|
||||
let keep_versions = options.keep_versions;
|
||||
let table_tasks = table_tasks
|
||||
.into_iter()
|
||||
.map(|(table_key, full_path)| {
|
||||
let live_main_floor = oldest_live_main_version_by_path.get(&full_path).copied();
|
||||
(table_key, full_path, live_main_floor)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if table_tasks.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
|
|
@ -911,7 +982,7 @@ pub async fn cleanup_all_tables(
|
|||
// cleanup is the convergence backstop, so it must do as much as it can and
|
||||
// converge on re-run rather than fail wholesale (invariant 13).
|
||||
let results: Vec<TableCleanupStats> = futures::stream::iter(table_tasks.into_iter())
|
||||
.map(|(table_key, full_path)| async move {
|
||||
.map(|(table_key, full_path, live_main_floor)| async move {
|
||||
let outcome: Result<RemovalStats> = async {
|
||||
crate::failpoints::maybe_fail(crate::failpoints::names::CLEANUP_TABLE_GC)?;
|
||||
// `cleanup_old_versions` is a Lance-only maintenance API not
|
||||
|
|
@ -919,9 +990,36 @@ pub async fn cleanup_all_tables(
|
|||
// above for the same rationale. Unwrap via `into_dataset()`.
|
||||
let handle = storage.open_dataset_head(&full_path, None).await?;
|
||||
let ds = handle.into_dataset();
|
||||
let before_version = keep_versions
|
||||
.map(|n| ds.version().version.saturating_sub(n as u64))
|
||||
.filter(|v| *v > 0);
|
||||
let requested_before_version = if let Some(keep) = keep_versions {
|
||||
// Lance versions are not safely derivable from HEAD
|
||||
// arithmetic after prior GC. Use the actual ordered
|
||||
// version list so `keep=N` retains exactly the newest N
|
||||
// available versions (with HEAD as the unavoidable floor
|
||||
// when N=0).
|
||||
let versions = ds
|
||||
.versions()
|
||||
.await
|
||||
.map_err(|error| OmniError::Lance(error.to_string()))?;
|
||||
let retain = (keep as usize).max(1);
|
||||
let cutoff = if versions.len() <= retain {
|
||||
versions.first()
|
||||
} else {
|
||||
versions.get(versions.len() - retain)
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
OmniError::manifest_internal(format!(
|
||||
"cleanup found no versions for open table '{table_key}'"
|
||||
))
|
||||
})?;
|
||||
Some(cutoff.version)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let before_version = match (requested_before_version, live_main_floor) {
|
||||
(Some(requested), Some(floor)) => Some(requested.min(floor)),
|
||||
(None, Some(floor)) => Some(floor),
|
||||
(requested, None) => requested,
|
||||
};
|
||||
let policy = CleanupPolicy {
|
||||
before_timestamp,
|
||||
before_version,
|
||||
|
|
|
|||
|
|
@ -1155,6 +1155,10 @@ impl StagedMutation {
|
|||
row_count: state.row_count,
|
||||
version_metadata: state.version_metadata,
|
||||
});
|
||||
crate::failpoints::maybe_fail(crate::failpoints::names::MUTATION_POST_TABLE_COMMIT)
|
||||
.map_err(|error| {
|
||||
OmniError::recovery_required(operation_id.clone(), error.to_string())
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Err(error) = confirm_occ_sidecar_phase_b(
|
||||
|
|
|
|||
|
|
@ -38,7 +38,15 @@ pub(crate) fn maybe_fail_retryable_contention(name: &str) -> Result<()> {
|
|||
/// reference these constants instead of bare string literals, so a typo is a
|
||||
/// compile error rather than a silently-never-firing failpoint.
|
||||
pub mod names {
|
||||
/// After Lance returns success from its two-phase native create, before
|
||||
/// OmniGraph acknowledges it. Recovery must classify the matching
|
||||
/// BranchContents as a completed create (lost acknowledgement).
|
||||
pub const BRANCH_CREATE_POST_NATIVE: &str = "branch_create.post_native";
|
||||
pub const BRANCH_DELETE_BEFORE_TABLE_CLEANUP: &str = "branch_delete.before_table_cleanup";
|
||||
/// After Lance returns success from native delete, before OmniGraph
|
||||
/// acknowledges it. Recovery must classify the absent BranchContents as a
|
||||
/// completed logical deletion.
|
||||
pub const BRANCH_DELETE_POST_NATIVE: &str = "branch_delete.post_native";
|
||||
/// Branch delete holds the schema, target-branch, and fresh-catalog table
|
||||
/// envelope and has completed its final recovery check, before the native
|
||||
/// manifest-ref mutation.
|
||||
|
|
@ -94,6 +102,11 @@ pub mod names {
|
|||
/// After every deferred first-touch table ref is created under a durable
|
||||
/// v3 sidecar, before any staged data transaction advances target HEAD.
|
||||
pub const MUTATION_POST_FORK_PRE_COMMIT: &str = "mutation.post_fork_pre_commit";
|
||||
/// After each exact staged table transaction advances HEAD, before the next
|
||||
/// table effect or Phase-B confirmation. Used to leave a real partial
|
||||
/// multi-table v3 attempt whose remaining first-touch fork still needs
|
||||
/// recovery cleanup.
|
||||
pub const MUTATION_POST_TABLE_COMMIT: &str = "mutation.post_table_commit";
|
||||
/// After the v3 ownership sidecar is durable but before the first deferred
|
||||
/// named-table ref is created. Recovery must accept the absent target ref.
|
||||
pub const MUTATION_POST_SIDECAR_PRE_FORK: &str = "mutation.post_sidecar_pre_fork";
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
// future Lance bumps stop needing this.
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
mod branch_control;
|
||||
pub mod changes;
|
||||
pub mod db;
|
||||
pub mod embedding;
|
||||
|
|
|
|||
|
|
@ -295,14 +295,12 @@ pub trait TableStorage: sealed::Sealed + Send + Sync + Debug {
|
|||
target_branch: &str,
|
||||
) -> Result<ForkOutcome<SnapshotHandle>>;
|
||||
|
||||
async fn delete_branch(&self, dataset_uri: &str, branch: &str) -> Result<()>;
|
||||
|
||||
/// Idempotent variant of `delete_branch` used by the best-effort fork
|
||||
/// reclaim under branch delete (`db/omnigraph.rs::cleanup_deleted_branch_tables`)
|
||||
/// Idempotent branch-tree reclaim used by the best-effort fork cleanup
|
||||
/// under branch delete (`db/omnigraph.rs::cleanup_deleted_branch_tables`)
|
||||
/// and by the orphan-fork reconciler in `optimize`. Tolerates an
|
||||
/// already-absent branch (both Lance's `RefNotFound` and the local-store
|
||||
/// `NotFound` quirk on a missing `tree/{branch}/` dir). A still-referenced
|
||||
/// branch (`RefConflict`) still surfaces as `OmniError::Lance`.
|
||||
/// branch (`RefConflict`) or live physical path-child remains an error.
|
||||
async fn force_delete_branch(&self, dataset_uri: &str, branch: &str) -> Result<()>;
|
||||
|
||||
/// List the named Lance branches present on the dataset at `dataset_uri`.
|
||||
|
|
@ -591,10 +589,6 @@ impl TableStorage for TableStore {
|
|||
)
|
||||
}
|
||||
|
||||
async fn delete_branch(&self, dataset_uri: &str, branch: &str) -> Result<()> {
|
||||
TableStore::delete_branch(self, dataset_uri, branch).await
|
||||
}
|
||||
|
||||
async fn force_delete_branch(&self, dataset_uri: &str, branch: &str) -> Result<()> {
|
||||
TableStore::force_delete_branch(self, dataset_uri, branch).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -277,19 +277,6 @@ impl TableStore {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn delete_branch(&self, dataset_uri: &str, branch: &str) -> Result<()> {
|
||||
let mut ds = crate::instrumentation::open_dataset(
|
||||
dataset_uri,
|
||||
crate::instrumentation::VersionResolution::Latest,
|
||||
Some(&self.session),
|
||||
crate::instrumentation::table_wrapper(),
|
||||
)
|
||||
.await?;
|
||||
ds.delete_branch(branch)
|
||||
.await
|
||||
.map_err(|e| OmniError::Lance(e.to_string()))
|
||||
}
|
||||
|
||||
/// List the named Lance branches present on the dataset at `dataset_uri`.
|
||||
/// The `cleanup` orphan reconciler diffs this against the manifest branch
|
||||
/// set to find orphaned per-table forks. `main`/default is not a named
|
||||
|
|
@ -311,15 +298,16 @@ impl TableStore {
|
|||
|
||||
/// Idempotently drop `branch` from the dataset at `dataset_uri`.
|
||||
///
|
||||
/// Unlike [`delete_branch`](Self::delete_branch), this tolerates an
|
||||
/// already-absent branch — both a missing contents ref (Lance's
|
||||
/// This tolerates an already-absent branch — both a missing contents ref (Lance's
|
||||
/// `force_delete_branch` handles that) and a missing `tree/{branch}/`
|
||||
/// directory (the local-store `NotFound` quirk pinned by
|
||||
/// `lance_surface_guards::force_delete_branch_semantics`). Safe to call on a
|
||||
/// possibly-orphaned or already-reclaimed fork.
|
||||
///
|
||||
/// A branch that still has referencing descendants (`RefConflict`) is NOT
|
||||
/// tolerated: that is a real ordering error and surfaces as `OmniError::Lance`.
|
||||
/// A branch that still has referencing descendants (`RefConflict`) or a
|
||||
/// live physical path-child is NOT tolerated: those are real ordering
|
||||
/// errors. The graph namespace prevents new path-prefix overlaps; surfacing
|
||||
/// legacy ones keeps cleanup from falsely reporting a reclaim Lance skipped.
|
||||
/// Used by the eager best-effort reclaim in `cleanup_deleted_branch_tables`
|
||||
/// and the `cleanup` orphan reconciler.
|
||||
pub async fn force_delete_branch(&self, dataset_uri: &str, branch: &str) -> Result<()> {
|
||||
|
|
@ -330,11 +318,7 @@ impl TableStore {
|
|||
crate::instrumentation::table_wrapper(),
|
||||
)
|
||||
.await?;
|
||||
match ds.force_delete_branch(branch).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(lance::Error::RefNotFound { .. }) | Err(lance::Error::NotFound { .. }) => Ok(()),
|
||||
Err(e) => Err(OmniError::Lance(e.to_string())),
|
||||
}
|
||||
crate::branch_control::force_delete_branch_idempotent(&mut ds, branch).await
|
||||
}
|
||||
|
||||
pub fn ensure_expected_version(
|
||||
|
|
@ -387,49 +371,28 @@ impl TableStore {
|
|||
.map_err(|e| OmniError::Lance(e.to_string()))?;
|
||||
self.ensure_expected_version(&source_ds, table_key, source_version)?;
|
||||
|
||||
if let Err(create_err) = source_ds
|
||||
.create_branch(target_branch, source_version, None)
|
||||
.await
|
||||
let created = match crate::branch_control::create_branch_recoverably(
|
||||
&mut source_ds,
|
||||
target_branch,
|
||||
source_version,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
// Disambiguate the failure: only a genuinely pre-existing ref is a
|
||||
// reclaim candidate. Mapping EVERY create_branch failure to
|
||||
// `RefAlreadyExists` would route a transient I/O / version / Lance
|
||||
// internal error into the destructive reclaim path. So check whether
|
||||
// the ref actually exists; if not, the failure is real — propagate
|
||||
// it (preserving error fidelity) rather than force-deleting.
|
||||
//
|
||||
// `list_branches` reads `_refs/branches/` from the store, so it sees
|
||||
// a fully-formed manifest-unreferenced fork (our common case — a
|
||||
// create_branch that completed but whose manifest publish did not).
|
||||
// It does NOT see a phase-1-only Lance "zombie" (tree dir written,
|
||||
// no BranchContents) — but neither does `cleanup`'s reconciler, also
|
||||
// list_branches-based. A zombie only forms if create_branch is
|
||||
// interrupted *between its two internal phases* (a far narrower
|
||||
// window than the manifest-publish gap), and it surfaces here as the
|
||||
// propagated create error requiring manual reclaim. We deliberately
|
||||
// do NOT force-delete on a not-found-ref failure: it is
|
||||
// indistinguishable from a transient error on a fresh create, and
|
||||
// force-deleting there is the destructive overreach this guard
|
||||
// removes. The caller holds the per-(table, branch) write queue, so
|
||||
// no in-process writer races this fork; a cross-process create
|
||||
// between our check and now is the documented one-winner-CAS gap and
|
||||
// propagates as a retryable error.
|
||||
let ref_exists = source_ds
|
||||
.list_branches()
|
||||
.await
|
||||
.map(|b| b.contains_key(target_branch))
|
||||
.unwrap_or(false);
|
||||
if ref_exists {
|
||||
crate::branch_control::BranchCreateOutcome::Created(dataset) => dataset,
|
||||
crate::branch_control::BranchCreateOutcome::RefAlreadyExists => {
|
||||
return Ok(ForkOutcome::RefAlreadyExists);
|
||||
}
|
||||
return Err(OmniError::Lance(create_err.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
// The ref is now independently durable. Any error from this point is an
|
||||
// ambiguous/post-effect outcome to the caller and must retain an armed
|
||||
// recovery intent rather than being treated as a safe pre-effect retry.
|
||||
crate::failpoints::maybe_fail(crate::failpoints::names::FORK_POST_CREATE_PRE_OPEN)?;
|
||||
|
||||
// Re-open through the shared session for normal cache behavior. The
|
||||
// returned handle above is used only as proof that the matching branch
|
||||
// dataset was openable during classification.
|
||||
drop(created);
|
||||
let ds = self
|
||||
.open_dataset_head(dataset_uri, Some(target_branch))
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -128,6 +128,19 @@ async fn branch_create_open_list_and_lazy_branching_work() {
|
|||
let uri = dir.path().to_str().unwrap();
|
||||
let mut main = init_and_load(&dir).await;
|
||||
|
||||
main.branch_create("feature").await.unwrap();
|
||||
// Reproduce Lance's phase-1-only crash state: keep the shallow-cloned
|
||||
// `tree/feature` dataset but remove BranchContents, its sole logical
|
||||
// authority. A same-name graph create must reclaim the zombie and retry,
|
||||
// rather than surfacing DatasetAlreadyExists forever.
|
||||
std::fs::remove_file(
|
||||
dir.path()
|
||||
.join("__manifest")
|
||||
.join("_refs")
|
||||
.join("branches")
|
||||
.join("feature.json"),
|
||||
)
|
||||
.unwrap();
|
||||
main.branch_create("feature").await.unwrap();
|
||||
assert_eq!(main.branch_list().await.unwrap(), vec!["main", "feature"]);
|
||||
|
||||
|
|
@ -1044,6 +1057,18 @@ async fn branch_created_from_non_main_inherits_branch_state() {
|
|||
.branch_create_from(ReadTarget::branch("feature"), "experiment")
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::remove_file(
|
||||
dir.path()
|
||||
.join("__manifest")
|
||||
.join("_refs")
|
||||
.join("branches")
|
||||
.join("experiment.json"),
|
||||
)
|
||||
.unwrap();
|
||||
feature
|
||||
.branch_create_from(ReadTarget::branch("feature"), "experiment")
|
||||
.await
|
||||
.expect("non-main create must also reclaim a clone-only target");
|
||||
|
||||
assert_eq!(
|
||||
feature.branch_list().await.unwrap(),
|
||||
|
|
@ -1530,6 +1555,17 @@ async fn branch_api_rejects_reserved_main_and_same_source_target_merge() {
|
|||
let err = db.branch_delete("main").await.unwrap_err();
|
||||
assert!(err.to_string().contains("cannot delete branch 'main'"));
|
||||
|
||||
let err = db.branch_create("bad branch").await.unwrap_err();
|
||||
assert!(err.to_string().contains("invalid") || err.to_string().contains("allowed"));
|
||||
assert!(
|
||||
!dir.path()
|
||||
.join("__manifest")
|
||||
.join("tree")
|
||||
.join("bad branch")
|
||||
.exists(),
|
||||
"branch names must be validated before Lance's shallow-clone phase"
|
||||
);
|
||||
|
||||
let err = db.branch_merge("main", "main").await.unwrap_err();
|
||||
assert!(err.to_string().contains("distinct source and target"));
|
||||
|
||||
|
|
@ -1574,6 +1610,91 @@ async fn branch_delete_removes_owned_table_branches_and_allows_recreate() {
|
|||
assert_eq!(count_rows_branch(&main, "feature", "node:Person").await, 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn branch_namespace_rejects_live_physical_path_prefix_collisions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut db = init_and_load(&dir).await;
|
||||
|
||||
db.branch_create("feature").await.unwrap();
|
||||
let err = db.branch_create("feature/child").await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("physical Lance path")
|
||||
&& err.to_string().contains("ancestors or descendants"),
|
||||
"prefix collision must be actionable; got: {err}"
|
||||
);
|
||||
assert!(
|
||||
!dir.path()
|
||||
.join("__manifest")
|
||||
.join("tree")
|
||||
.join("feature")
|
||||
.join("child")
|
||||
.exists(),
|
||||
"prefix admission must reject before Lance creates the target clone"
|
||||
);
|
||||
|
||||
db.branch_delete("feature").await.unwrap();
|
||||
db.branch_create("feature/child").await.unwrap();
|
||||
let err = db.branch_create("feature").await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("physical Lance path")
|
||||
&& err.to_string().contains("feature/child"),
|
||||
"ancestor creation must reject the inverse prefix collision; got: {err}"
|
||||
);
|
||||
assert!(
|
||||
!lance::Dataset::open(&format!("{}/__manifest", dir.path().display()))
|
||||
.await
|
||||
.unwrap()
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains_key("feature"),
|
||||
"inverse admission refusal must not create an ancestor ref"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn branch_delete_refuses_legacy_physical_path_children() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let uri = dir.path().to_str().unwrap();
|
||||
let mut db = init_and_load(&dir).await;
|
||||
db.branch_create("feature/child").await.unwrap();
|
||||
|
||||
// Forge a graph written before the prefix-disjoint namespace invariant.
|
||||
// Lance permits both refs but intentionally cannot reclaim the ancestor's
|
||||
// dataset directory while the child path is live.
|
||||
let mut manifest = lance::Dataset::open(&format!("{uri}/__manifest"))
|
||||
.await
|
||||
.unwrap();
|
||||
let version = manifest.version().version;
|
||||
manifest
|
||||
.create_branch("feature", version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = db.branch_delete("feature").await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("feature/child")
|
||||
&& err.to_string().contains("delete the child branch first"),
|
||||
"legacy prefix collisions must be deleted leaf-first; got: {err}"
|
||||
);
|
||||
assert!(
|
||||
manifest
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains_key("feature"),
|
||||
"refusal must not remove ancestor authority"
|
||||
);
|
||||
|
||||
db.branch_delete("feature/child").await.unwrap();
|
||||
db.branch_delete("feature").await.unwrap();
|
||||
assert_eq!(
|
||||
db.branch_list().await.unwrap(),
|
||||
vec!["main"],
|
||||
"legacy overlap must converge when deleted leaf-first"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn branch_delete_rejects_branches_still_referenced_by_descendants() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -56,6 +56,50 @@ fn node_table_uri(root: &str, type_name: &str) -> String {
|
|||
format!("{}/nodes/{hash:016x}", root.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
// Lance can durably complete a native ref mutation while the caller observes
|
||||
// an error (for example, a lost object-store acknowledgement). BranchContents
|
||||
// is the logical authority in both directions: matching create metadata means
|
||||
// success, and an absent ref means delete succeeded even if tree cleanup or the
|
||||
// acknowledgement failed. Neither control emits graph lineage or a main-table
|
||||
// version.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn native_branch_controls_reclassify_lost_acknowledgements() {
|
||||
let _scenario = FailScenario::setup();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = helpers::init_and_load(&dir).await;
|
||||
let before_version = version_main(&db).await.unwrap();
|
||||
let before_commits = db.list_commits(Some("main")).await.unwrap().len();
|
||||
|
||||
{
|
||||
let _fp = ScopedFailPoint::new(names::BRANCH_CREATE_POST_NATIVE, "return");
|
||||
db.branch_create("feature")
|
||||
.await
|
||||
.expect("matching BranchContents must classify a lost create acknowledgement");
|
||||
}
|
||||
assert!(
|
||||
db.branch_list()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|branch| branch == "feature")
|
||||
);
|
||||
|
||||
{
|
||||
let _fp = ScopedFailPoint::new(names::BRANCH_DELETE_POST_NATIVE, "return");
|
||||
db.branch_delete("feature")
|
||||
.await
|
||||
.expect("absent BranchContents must classify a lost delete acknowledgement");
|
||||
}
|
||||
assert_eq!(db.branch_list().await.unwrap(), vec!["main".to_string()]);
|
||||
assert_eq!(version_main(&db).await.unwrap(), before_version);
|
||||
assert_eq!(
|
||||
db.list_commits(Some("main")).await.unwrap().len(),
|
||||
before_commits,
|
||||
"native branch controls must not manufacture graph lineage"
|
||||
);
|
||||
}
|
||||
|
||||
// Branch delete flips the manifest authority first, then reclaims the per-table
|
||||
// forks best-effort. A failure during that reclaim (here, the
|
||||
// `branch_delete.before_table_cleanup` failpoint, standing in for a transient
|
||||
|
|
@ -647,6 +691,31 @@ async fn armed_first_touch_recovery_accepts_missing_target_ref() {
|
|||
"precondition: crash happened before the target ref was created"
|
||||
);
|
||||
|
||||
// Materialize the narrower Lance crash window underneath the already-
|
||||
// durable writer intent: shallow clone present, BranchContents absent.
|
||||
// Full recovery must not equate "not listed" with "no physical fork".
|
||||
let mut person = lance::Dataset::open(&person_uri).await.unwrap();
|
||||
let person_version = person.version().version;
|
||||
person
|
||||
.create_branch("feature", person_version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::remove_file(
|
||||
std::path::Path::new(&person_uri)
|
||||
.join("_refs")
|
||||
.join("branches")
|
||||
.join("feature.json"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
std::path::Path::new(&person_uri)
|
||||
.join("tree")
|
||||
.join("feature")
|
||||
.exists(),
|
||||
"precondition: clone-only target tree exists"
|
||||
);
|
||||
drop(person);
|
||||
|
||||
// Stage A is a synchronous, branch-aware barrier. The Armed sidecar has no
|
||||
// table effect for a HEAD-drift check to discover, but it is still ownership:
|
||||
// another mutation on the same branch must name it and stop before preparing
|
||||
|
|
@ -727,6 +796,314 @@ async fn armed_first_touch_recovery_accepts_missing_target_ref() {
|
|||
.exists(),
|
||||
"full recovery must remove the empty armed intent"
|
||||
);
|
||||
assert!(
|
||||
!std::path::Path::new(&person_uri)
|
||||
.join("tree")
|
||||
.join("feature")
|
||||
.exists(),
|
||||
"full recovery must reclaim an unlisted clone-only table fork"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn armed_first_touch_recovery_defers_legacy_path_overlap_until_leaf_delete() {
|
||||
let _scenario = FailScenario::setup();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let uri = dir.path().to_str().unwrap().to_string();
|
||||
let db = helpers::init_and_load(&dir).await;
|
||||
let main_rows = helpers::count_rows(&db, "node:Person").await;
|
||||
|
||||
// Materialize the leaf first so its graph snapshot owns a matching
|
||||
// per-table Lance branch. New OmniGraph versions reject the inverse live
|
||||
// namespace, but old stores could contain it.
|
||||
db.branch_create("feature/child").await.unwrap();
|
||||
db.mutate(
|
||||
"feature/child",
|
||||
MUTATION_QUERIES,
|
||||
"insert_person",
|
||||
&mixed_params(&[("$name", "Leaf")], &[("$age", 22)]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Forge only the legacy graph-level ancestor admission. It inherits main,
|
||||
// so an interrupted first write will own an unpublished table fork.
|
||||
let mut manifest = lance::Dataset::open(&format!("{uri}/__manifest"))
|
||||
.await
|
||||
.unwrap();
|
||||
let manifest_version = manifest.version().version;
|
||||
manifest
|
||||
.create_branch("feature", manifest_version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(manifest);
|
||||
|
||||
{
|
||||
let _failpoint = ScopedFailPoint::new(names::MUTATION_POST_SIDECAR_PRE_FORK, "return");
|
||||
let error = db
|
||||
.mutate(
|
||||
"feature",
|
||||
MUTATION_QUERIES,
|
||||
"insert_person",
|
||||
&mixed_params(&[("$name", "Ancestor")], &[("$age", 23)]),
|
||||
)
|
||||
.await
|
||||
.expect_err("failure after arming must leave the ancestor recovery intent");
|
||||
assert!(matches!(error, OmniError::RecoveryRequired { .. }));
|
||||
}
|
||||
let operation_id = single_sidecar_operation_id(dir.path());
|
||||
|
||||
// Reproduce Lance's narrower clone-only crash window under the already
|
||||
// durable intent. The live leaf shares the ancestor's physical path, so a
|
||||
// force-delete of the ancestor cannot safely complete yet.
|
||||
let person_uri = node_table_uri(&uri, "Person");
|
||||
let mut person = lance::Dataset::open(&person_uri).await.unwrap();
|
||||
let person_version = person.version().version;
|
||||
person
|
||||
.create_branch("feature", person_version, None)
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::remove_file(
|
||||
std::path::Path::new(&person_uri)
|
||||
.join("_refs")
|
||||
.join("branches")
|
||||
.join("feature.json"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
person
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains_key("feature/child"),
|
||||
"precondition: live leaf table branch exists"
|
||||
);
|
||||
assert!(
|
||||
!person
|
||||
.list_branches()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains_key("feature"),
|
||||
"precondition: ancestor is clone-only"
|
||||
);
|
||||
drop(person);
|
||||
drop(db);
|
||||
|
||||
let recovered = Omnigraph::open(&uri)
|
||||
.await
|
||||
.expect("legacy path overlap must defer cleanup instead of wedging read-write open");
|
||||
assert!(
|
||||
dir.path()
|
||||
.join("__recovery")
|
||||
.join(format!("{operation_id}.json"))
|
||||
.exists(),
|
||||
"deferred cleanup must retain its ownership sidecar"
|
||||
);
|
||||
assert_eq!(
|
||||
helpers::count_rows_branch(&recovered, "feature", "node:Person").await,
|
||||
main_rows,
|
||||
"the empty ancestor intent must remain unpublished"
|
||||
);
|
||||
|
||||
recovered
|
||||
.branch_delete("feature/child")
|
||||
.await
|
||||
.expect("open handle must permit the documented leaf-first remediation");
|
||||
drop(recovered);
|
||||
|
||||
let recovered = Omnigraph::open(&uri)
|
||||
.await
|
||||
.expect("the next Full sweep must finish ancestor cleanup");
|
||||
assert!(
|
||||
!dir.path()
|
||||
.join("__recovery")
|
||||
.join(format!("{operation_id}.json"))
|
||||
.exists(),
|
||||
"recovery must retire the intent after the path child is gone"
|
||||
);
|
||||
assert!(
|
||||
!std::path::Path::new(&person_uri)
|
||||
.join("tree")
|
||||
.join("feature")
|
||||
.exists(),
|
||||
"the clone-only ancestor tree must be reclaimed after leaf deletion"
|
||||
);
|
||||
assert_eq!(
|
||||
helpers::count_rows_branch(&recovered, "feature", "node:Person").await,
|
||||
main_rows
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn partial_first_touch_recovery_fails_closed_on_legacy_path_overlap() {
|
||||
let _scenario = FailScenario::setup();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let uri = dir.path().to_str().unwrap().to_string();
|
||||
let db = helpers::init_and_load(&dir).await;
|
||||
let main_person_rows = helpers::count_rows(&db, "node:Person").await;
|
||||
let main_edge_rows = helpers::count_rows(&db, "edge:Knows").await;
|
||||
|
||||
let main_snapshot = db.snapshot_of("main").await.unwrap();
|
||||
let table_pins = ["node:Person", "edge:Knows"]
|
||||
.into_iter()
|
||||
.map(|table_key| {
|
||||
let entry = main_snapshot.entry(table_key).unwrap();
|
||||
(
|
||||
table_key.to_string(),
|
||||
format!("{uri}/{}", entry.table_path),
|
||||
entry.table_version,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
db.branch_create("feature").await.unwrap();
|
||||
let operation_id = {
|
||||
let _failpoint = ScopedFailPoint::new(names::MUTATION_POST_TABLE_COMMIT, "return");
|
||||
let error = db
|
||||
.mutate(
|
||||
"feature",
|
||||
MUTATION_QUERIES,
|
||||
"insert_person_and_friend",
|
||||
&mixed_params(
|
||||
&[("$name", "Ancestor"), ("$friend", "Alice")],
|
||||
&[("$age", 23)],
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect_err("the first exact table effect must leave a partial v3 intent");
|
||||
match error {
|
||||
OmniError::RecoveryRequired { operation_id, .. } => operation_id,
|
||||
other => panic!("expected RecoveryRequired, got {other}"),
|
||||
}
|
||||
};
|
||||
assert_eq!(single_sidecar_operation_id(dir.path()), operation_id);
|
||||
|
||||
// All deferred first-touch refs are created before the table-commit loop.
|
||||
// Staging order is intentionally non-semantic, so either table may own the
|
||||
// one durable effect while its sibling remains at the exact fork point.
|
||||
let mut head_deltas = Vec::new();
|
||||
let mut heads_before_open = Vec::new();
|
||||
for (table_key, table_uri, expected_version) in &table_pins {
|
||||
let mut root = lance::Dataset::open(table_uri).await.unwrap();
|
||||
let branches = root.list_branches().await.unwrap();
|
||||
assert!(
|
||||
branches.contains_key("feature"),
|
||||
"precondition: {table_key} has its armed first-touch ref"
|
||||
);
|
||||
let head = root
|
||||
.checkout_branch("feature")
|
||||
.await
|
||||
.unwrap()
|
||||
.version()
|
||||
.version;
|
||||
heads_before_open.push((table_key.clone(), head));
|
||||
head_deltas.push(head.checked_sub(*expected_version).unwrap());
|
||||
|
||||
// Forge the legacy namespace only after the partial effect exists. New
|
||||
// OmniGraph versions reject this path-prefix overlap at branch create,
|
||||
// but old stores can contain it. Clone from the exact feature HEAD so
|
||||
// the child itself introduces no extra ancestor movement.
|
||||
root.create_branch("feature/child", ("feature", head), None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
head_deltas.sort_unstable();
|
||||
assert_eq!(
|
||||
head_deltas,
|
||||
vec![0, 1],
|
||||
"exactly one table effect must be durable while its sibling remains an untouched fork"
|
||||
);
|
||||
|
||||
let mut manifest = lance::Dataset::open(&format!("{uri}/__manifest"))
|
||||
.await
|
||||
.unwrap();
|
||||
let feature_manifest_version = manifest
|
||||
.checkout_branch("feature")
|
||||
.await
|
||||
.unwrap()
|
||||
.version()
|
||||
.version;
|
||||
manifest
|
||||
.create_branch("feature/child", ("feature", feature_manifest_version), None)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(manifest);
|
||||
|
||||
let open_error = match Omnigraph::open(&uri).await {
|
||||
Ok(_) => panic!(
|
||||
"Full recovery must not return a writable handle while owned effects remain unrolled"
|
||||
),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
open_error.to_string().contains("owns physical effects")
|
||||
&& open_error.to_string().contains("feature/child"),
|
||||
"failure must explain the safe leaf-first remediation boundary: {open_error}"
|
||||
);
|
||||
assert!(
|
||||
dir.path()
|
||||
.join("__recovery")
|
||||
.join(format!("{operation_id}.json"))
|
||||
.exists(),
|
||||
"failed Full recovery must retain exact ownership"
|
||||
);
|
||||
let manifest_after_failed_open = lance::Dataset::open(&format!("{uri}/__manifest"))
|
||||
.await
|
||||
.unwrap()
|
||||
.checkout_branch("feature")
|
||||
.await
|
||||
.unwrap()
|
||||
.version()
|
||||
.version;
|
||||
assert_eq!(
|
||||
manifest_after_failed_open, feature_manifest_version,
|
||||
"failed rollback preflight must not publish the manifest"
|
||||
);
|
||||
for ((table_key, table_uri, _), (_, before)) in table_pins.iter().zip(heads_before_open.iter())
|
||||
{
|
||||
let root = lance::Dataset::open(table_uri).await.unwrap();
|
||||
let after = root
|
||||
.checkout_branch("feature")
|
||||
.await
|
||||
.unwrap()
|
||||
.version()
|
||||
.version;
|
||||
assert_eq!(
|
||||
after, *before,
|
||||
"failed preflight must not restore or publish {table_key}"
|
||||
);
|
||||
}
|
||||
|
||||
// A handle that predates the interrupted attempt can remove the graph leaf
|
||||
// under the normal branch-control gates. This fixture forged its table refs
|
||||
// outside the manifest, so finish the documented offline leaf cleanup at
|
||||
// Lance level. Once the physical overlap is gone, the next quiesced Full
|
||||
// sweep can compensate the owned effect atomically.
|
||||
db.branch_delete("feature/child").await.unwrap();
|
||||
for (_, table_uri, _) in &table_pins {
|
||||
lance::Dataset::open(table_uri)
|
||||
.await
|
||||
.unwrap()
|
||||
.force_delete_branch("feature/child")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
drop(db);
|
||||
|
||||
let recovered = Omnigraph::open(&uri)
|
||||
.await
|
||||
.expect("rollback must converge after leaf-first remediation");
|
||||
assert!(helpers::recovery::sidecar_operation_ids(dir.path()).is_empty());
|
||||
assert_eq!(
|
||||
helpers::count_rows_branch(&recovered, "feature", "node:Person").await,
|
||||
main_person_rows
|
||||
);
|
||||
assert_eq!(
|
||||
helpers::count_rows_branch(&recovered, "feature", "edge:Knows").await,
|
||||
main_edge_rows
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -338,7 +338,7 @@ async fn _compile_uncommitted_merge_insert_field_shape() -> lance::Result<()> {
|
|||
// The branch-delete reconciler (`db/omnigraph/optimize.rs::reconcile_orphaned_branches`)
|
||||
// and the eager best-effort reclaim in `cleanup_deleted_branch_tables` call
|
||||
// `force_delete_branch` to drop orphaned branch refs. The single-authority
|
||||
// design relies on three facts pinned here:
|
||||
// design relies on five facts pinned here:
|
||||
// 1. plain `delete_branch` errors on a missing ref (so the design uses the
|
||||
// force variant instead);
|
||||
// 2. `force_delete_branch` removes an existing (forked) branch — the orphan
|
||||
|
|
@ -347,7 +347,14 @@ async fn _compile_uncommitted_merge_insert_field_shape() -> lance::Result<()> {
|
|||
// errors on the local store, because `remove_dir_all`'s NotFound is not
|
||||
// caught for Lance's native error variant. `TableStore::force_delete_branch`
|
||||
// wraps this to be fully idempotent. Pin the raw quirk so a future Lance
|
||||
// fix (which would let us simplify the wrapper) is noticed.
|
||||
// fix (which would let us simplify the wrapper) is noticed;
|
||||
// 4. a clone-only zombie (branch dataset present, BranchContents absent)
|
||||
// blocks raw create and is reclaimed by `force_delete_branch`. Lance's
|
||||
// create is explicitly two-phase, so this is the crash state OmniGraph's
|
||||
// native branch-control wrapper must heal before retrying;
|
||||
// 5. a live slash-name path-child makes force delete remove an ancestor's
|
||||
// BranchContents but intentionally retain its dataset files. OmniGraph's
|
||||
// prefix-disjoint live-name invariant prevents this false-success shape.
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_delete_branch_semantics() {
|
||||
|
|
@ -379,6 +386,56 @@ async fn force_delete_branch_semantics() {
|
|||
"force_delete_branch on a fully-absent branch no longer errors — \
|
||||
TableStore::force_delete_branch's NotFound tolerance can be simplified."
|
||||
);
|
||||
|
||||
// (4) Exact phase-1-only create state: create the shallow-cloned branch
|
||||
// dataset, then remove only its authoritative BranchContents ref. This is
|
||||
// the same fixture Lance's own dataset-versioning test uses for a zombie.
|
||||
ds.create_branch("zombie", base, None).await.unwrap();
|
||||
std::fs::remove_file(
|
||||
std::path::Path::new(uri)
|
||||
.join("_refs")
|
||||
.join("branches")
|
||||
.join("zombie.json"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!ds.list_branches().await.unwrap().contains_key("zombie"),
|
||||
"BranchContents is the authority; the clone-only tree must not list as a branch"
|
||||
);
|
||||
assert!(
|
||||
ds.create_branch("zombie", base, None).await.is_err(),
|
||||
"the clone-only tree should block an unclassified raw create"
|
||||
);
|
||||
ds.force_delete_branch("zombie").await.unwrap();
|
||||
assert!(
|
||||
!std::path::Path::new(uri)
|
||||
.join("tree")
|
||||
.join("zombie")
|
||||
.exists(),
|
||||
"force_delete_branch must reclaim the clone-only tree"
|
||||
);
|
||||
|
||||
// (5) Slash-separated names overlap physically. A path-child created from
|
||||
// main is not a lineage descendant of its lexical ancestor, so raw force
|
||||
// delete removes the ancestor ref but deliberately leaves its dataset
|
||||
// files to avoid recursively deleting the child.
|
||||
ds.create_branch("ancestor/child", base, None)
|
||||
.await
|
||||
.unwrap();
|
||||
ds.create_branch("ancestor", base, None).await.unwrap();
|
||||
ds.force_delete_branch("ancestor").await.unwrap();
|
||||
assert!(
|
||||
!ds.list_branches().await.unwrap().contains_key("ancestor"),
|
||||
"raw force delete still removes authoritative ancestor metadata"
|
||||
);
|
||||
assert!(
|
||||
std::path::Path::new(uri)
|
||||
.join("tree")
|
||||
.join("ancestor")
|
||||
.join("_versions")
|
||||
.exists(),
|
||||
"Lance must retain ancestor dataset files while a physical path-child is live"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Guard 10: blob-column compaction works in this Lance ------------------
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ use omnigraph::loader::{LoadMode, load_jsonl};
|
|||
use omnigraph::table_store::{IndexCoverage, TableStore};
|
||||
|
||||
use helpers::{
|
||||
MUTATION_QUERIES, TEST_DATA, TEST_SCHEMA, count_rows, init_and_load, mixed_params, mutate_main,
|
||||
snapshot_main,
|
||||
MUTATION_QUERIES, TEST_DATA, TEST_SCHEMA, count_rows, count_rows_branch, init_and_load,
|
||||
mixed_params, mutate_main, snapshot_main,
|
||||
};
|
||||
|
||||
/// Filesystem URI of a node sub-table, mirroring the engine's layout
|
||||
|
|
@ -925,7 +925,9 @@ async fn cleanup_without_any_policy_option_errors() {
|
|||
#[tokio::test]
|
||||
async fn cleanup_keep_one_preserves_head_and_table_remains_readable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let uri = dir.path().to_str().unwrap().to_string();
|
||||
let mut db = init_and_load(&dir).await;
|
||||
add_person_fragments(&mut db).await;
|
||||
|
||||
let people_before = count_rows(&db, "node:Person").await;
|
||||
assert!(
|
||||
|
|
@ -933,9 +935,21 @@ async fn cleanup_keep_one_preserves_head_and_table_remains_readable() {
|
|||
"fixture should seed Person rows for this test to be meaningful"
|
||||
);
|
||||
|
||||
// Most aggressive version-based cleanup short of forcing keep=0. Lance's
|
||||
// contract is that head is always preserved regardless, so the table
|
||||
// must remain openable and rows must still be visible.
|
||||
let person_uri = node_table_uri(&uri, "Person");
|
||||
assert!(
|
||||
Dataset::open(&person_uri)
|
||||
.await
|
||||
.unwrap()
|
||||
.versions()
|
||||
.await
|
||||
.unwrap()
|
||||
.len()
|
||||
> 1,
|
||||
"precondition: Person must have history to collect"
|
||||
);
|
||||
|
||||
// Most aggressive version-based cleanup short of forcing keep=0. `keep`
|
||||
// is exact over the available version list, not HEAD arithmetic.
|
||||
let _stats = db
|
||||
.cleanup(CleanupPolicyOptions {
|
||||
keep_versions: Some(1),
|
||||
|
|
@ -945,6 +959,52 @@ async fn cleanup_keep_one_preserves_head_and_table_remains_readable() {
|
|||
.unwrap();
|
||||
|
||||
assert_eq!(count_rows(&db, "node:Person").await, people_before);
|
||||
assert_eq!(
|
||||
Dataset::open(&person_uri)
|
||||
.await
|
||||
.unwrap()
|
||||
.versions()
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1,
|
||||
"keep=1 must retain exactly the latest available version"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_keep_exceeding_history_preserves_every_available_version() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let uri = dir.path().to_str().unwrap().to_string();
|
||||
let mut db = init_and_load(&dir).await;
|
||||
let person_uri = node_table_uri(&uri, "Person");
|
||||
let before = Dataset::open(&person_uri)
|
||||
.await
|
||||
.unwrap()
|
||||
.versions()
|
||||
.await
|
||||
.unwrap()
|
||||
.len();
|
||||
assert!(before > 1, "fixture must contain version history");
|
||||
|
||||
db.cleanup(CleanupPolicyOptions {
|
||||
keep_versions: Some(10),
|
||||
older_than: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let after = Dataset::open(&person_uri)
|
||||
.await
|
||||
.unwrap()
|
||||
.versions()
|
||||
.await
|
||||
.unwrap()
|
||||
.len();
|
||||
assert_eq!(
|
||||
after, before,
|
||||
"keep greater than available history must not become an unbounded cleanup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -970,6 +1030,224 @@ async fn cleanup_older_than_zero_preserves_head() {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_preserves_main_version_pinned_by_live_lazy_branch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut db = init_and_load(&dir).await;
|
||||
|
||||
db.branch_create("feature").await.unwrap();
|
||||
let feature_before = db.snapshot_of(ReadTarget::branch("feature")).await.unwrap();
|
||||
let feature_person = feature_before.entry("node:Person").unwrap();
|
||||
assert_eq!(
|
||||
feature_person.table_branch, None,
|
||||
"precondition: Person must still be inherited lazily from main"
|
||||
);
|
||||
let pinned_main_version = feature_person.table_version;
|
||||
let feature_people_before = count_rows_branch(&db, "feature", "node:Person").await;
|
||||
|
||||
// Move main far enough that keep=1 would collect the version inherited by
|
||||
// the lazy branch unless cleanup accounts for graph-level branch pins.
|
||||
add_person_fragments(&mut db).await;
|
||||
let main_person_version = db
|
||||
.snapshot_of(ReadTarget::branch("main"))
|
||||
.await
|
||||
.unwrap()
|
||||
.entry("node:Person")
|
||||
.unwrap()
|
||||
.table_version;
|
||||
assert!(
|
||||
pinned_main_version < main_person_version.saturating_sub(1),
|
||||
"precondition: lazy-branch pin must fall outside keep=1 retention"
|
||||
);
|
||||
|
||||
db.cleanup(CleanupPolicyOptions {
|
||||
keep_versions: Some(1),
|
||||
older_than: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
count_rows_branch(&db, "feature", "node:Person").await,
|
||||
feature_people_before,
|
||||
"cleanup must preserve the exact main-table version inherited by a live lazy branch"
|
||||
);
|
||||
|
||||
// The same floor must constrain a time-only policy. Lance combines the
|
||||
// timestamp and version predicates with AND, so injecting the branch pin
|
||||
// as `before_version` keeps the inherited version even when every old
|
||||
// manifest satisfies the timestamp cutoff.
|
||||
db.cleanup(CleanupPolicyOptions {
|
||||
keep_versions: None,
|
||||
older_than: Some(Duration::from_secs(0)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count_rows_branch(&db, "feature", "node:Person").await,
|
||||
feature_people_before,
|
||||
"time-only cleanup must honor the same live lazy-branch floor"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_uses_oldest_pin_across_multiple_live_lazy_branches() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut db = init_and_load(&dir).await;
|
||||
|
||||
db.branch_create("a-old").await.unwrap();
|
||||
let old_rows = count_rows_branch(&db, "a-old", "node:Person").await;
|
||||
add_person_fragments(&mut db).await;
|
||||
db.branch_create("z-new").await.unwrap();
|
||||
let new_rows = count_rows_branch(&db, "z-new", "node:Person").await;
|
||||
mutate_main(
|
||||
&mut db,
|
||||
MUTATION_QUERIES,
|
||||
"insert_person",
|
||||
&mixed_params(&[("$name", "Ivan")], &[("$age", 44)]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.cleanup(CleanupPolicyOptions {
|
||||
keep_versions: Some(1),
|
||||
older_than: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
count_rows_branch(&db, "a-old", "node:Person").await,
|
||||
old_rows,
|
||||
"the oldest live pin must win over later branch pins"
|
||||
);
|
||||
assert_eq!(
|
||||
count_rows_branch(&db, "z-new", "node:Person").await,
|
||||
new_rows,
|
||||
"newer lazy pins must remain readable too"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_fails_closed_when_live_lazy_branch_pin_is_unopenable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let uri = dir.path().to_str().unwrap().to_string();
|
||||
let mut db = init_and_load(&dir).await;
|
||||
|
||||
db.branch_create("feature").await.unwrap();
|
||||
let pinned_main_version = db
|
||||
.snapshot_of(ReadTarget::branch("feature"))
|
||||
.await
|
||||
.unwrap()
|
||||
.entry("node:Person")
|
||||
.unwrap()
|
||||
.table_version;
|
||||
add_person_fragments(&mut db).await;
|
||||
|
||||
// Simulate damage created by an older cleanup implementation: raw Lance
|
||||
// sees no native Person branch for the lazy graph branch and removes its
|
||||
// inherited main manifest.
|
||||
let person_uri = node_table_uri(&uri, "Person");
|
||||
let ds = Dataset::open(&person_uri).await.unwrap();
|
||||
let head = ds.version().version;
|
||||
assert!(pinned_main_version < head, "precondition: main advanced");
|
||||
let removed = lance::dataset::cleanup::cleanup_old_versions(
|
||||
&ds,
|
||||
lance::dataset::cleanup::CleanupPolicy {
|
||||
before_timestamp: None,
|
||||
before_version: Some(head),
|
||||
delete_unverified: false,
|
||||
error_if_tagged_old_versions: false,
|
||||
clean_referenced_branches: false,
|
||||
delete_rate_limit: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
removed.old_versions > 0,
|
||||
"precondition: raw Lance cleanup removed old main versions"
|
||||
);
|
||||
let company_uri = node_table_uri(&uri, "Company");
|
||||
let company_versions_before = Dataset::open(&company_uri)
|
||||
.await
|
||||
.unwrap()
|
||||
.versions()
|
||||
.await
|
||||
.unwrap()
|
||||
.len();
|
||||
|
||||
let err = db
|
||||
.cleanup(CleanupPolicyOptions {
|
||||
keep_versions: Some(1),
|
||||
older_than: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("cleanup must fail closed when a live lazy pin cannot be opened");
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("could not classify live branch 'feature'")
|
||||
&& message.contains("node:Person"),
|
||||
"error must identify the unclassifiable live reference; got: {message}"
|
||||
);
|
||||
assert_eq!(
|
||||
Dataset::open(&company_uri)
|
||||
.await
|
||||
.unwrap()
|
||||
.versions()
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
company_versions_before,
|
||||
"the graph-wide preflight must fail before any unrelated table GC"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_refuses_uncovered_main_head_drift_before_any_version_gc() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().to_str().unwrap().to_string();
|
||||
let mut db = init_and_load(&dir).await;
|
||||
let (manifest_version, head_version, _) = forge_person_compaction_drift(&mut db, &root).await;
|
||||
let company_uri = node_table_uri(&root, "Company");
|
||||
let company_versions_before = Dataset::open(&company_uri)
|
||||
.await
|
||||
.unwrap()
|
||||
.versions()
|
||||
.await
|
||||
.unwrap()
|
||||
.len();
|
||||
|
||||
let err = db
|
||||
.cleanup(CleanupPolicyOptions {
|
||||
keep_versions: Some(1),
|
||||
older_than: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("cleanup must not garbage-collect around uncovered HEAD drift");
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("uncovered HEAD drift")
|
||||
&& message.contains("node:Person")
|
||||
&& message.contains("repair"),
|
||||
"drift refusal must be actionable; got: {message}"
|
||||
);
|
||||
let (manifest_after, head_after, _) = person_manifest_and_head(&db, &root).await;
|
||||
assert_eq!(manifest_after, manifest_version);
|
||||
assert_eq!(head_after, head_version);
|
||||
assert_eq!(
|
||||
Dataset::open(&company_uri)
|
||||
.await
|
||||
.unwrap()
|
||||
.versions()
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
company_versions_before,
|
||||
"drift preflight must abort before unrelated table history is collected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_then_optimize_preserves_rows_and_table_remains_writable() {
|
||||
// Cleanup destroys version history; the concern is that subsequent
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue