diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2994d3c1..9859748b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -595,13 +595,13 @@ jobs: fi grep -Fq "v0.9 refusal and export/import rebuild completed" "$test_log" \ || { echo "::error::v0.9 upgrade regression did not complete"; exit 1; } - grep -Eq "test current_v7_refuses_and_rebuilds_genuine_v09_graph_end_to_end \\.\\.\\. ok" "$test_log" \ + grep -Eq "test current_v8_refuses_and_rebuilds_genuine_v09_graph_end_to_end \\.\\.\\. ok" "$test_log" \ || { echo "::error::exact v0.9 refusal and rebuild regression did not pass"; exit 1; } if grep -Fq "skipping immediate-predecessor v6 upgrade test" "$test_log"; then echo "::error::v6 format fence skipped despite a configured 0.10 release binary" exit 1 fi - grep -Eq "test current_v7_refuses_and_rebuilds_genuine_v6_and_v6_refuses_v7 \\.\\.\\. ok" "$test_log" \ + grep -Eq "test current_v8_refuses_and_rebuilds_genuine_v6_and_v6_refuses_v8 \\.\\.\\. ok" "$test_log" \ || { echo "::error::exact v6 format fence did not pass"; exit 1; } @@ -685,8 +685,8 @@ jobs: cargo test --locked -p omnigraph-engine --test forbidden_apis --features failpoints -- --test-threads=1 2>&1 | tee "$test_log" python3 scripts/check-storage-upgrade-ci.py --check-log protocol "$test_log" - v5_v7_format_fence: - name: V5 ↔ V7 Format Fence + v5_v8_format_fence: + name: V5 ↔ V8 Format Fence needs: classify_changes # Pull requests too, as a reporting context like `Test Workspace`: a # fence red reports on the pull request instead of first appearing on `main`. @@ -719,7 +719,7 @@ jobs: with: workspaces: | . -> target - key: crossversion-v5-v7 + key: crossversion-v5-v8 save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true @@ -755,21 +755,21 @@ jobs: --target-dir "$GITHUB_WORKSPACE/target" echo "OMNIGRAPH_V5_BIN=$v5_bin" >> "$GITHUB_ENV" - - name: Run exact v5↔v7 refusal and rebuild fence + - name: Run exact v5↔v8 refusal and rebuild fence run: | set -euo pipefail - test_log="$RUNNER_TEMP/v5-v7-format-fence.log" + test_log="$RUNNER_TEMP/v5-v8-format-fence.log" cargo test --locked -p omnigraph-cli --test crossversion_upgrade \ - current_v7_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v7 \ + current_v8_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v8 \ -- --exact --nocapture 2>&1 | tee "$test_log" if grep -Fq "skipping immediate-predecessor v5 upgrade test" "$test_log"; then - echo "::error::v5↔v7 format fence skipped despite a configured predecessor binary" + echo "::error::v5↔v8 format fence skipped despite a configured predecessor binary" exit 1 fi - grep -Eq "test current_v7_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v7 \.\.\. ok" "$test_log" \ - || { echo "::error::exact v5↔v7 format fence did not execute normally"; exit 1; } + grep -Eq "test current_v8_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v8 \.\.\. ok" "$test_log" \ + || { echo "::error::exact v5↔v8 format fence did not execute normally"; exit 1; } grep -Eq "test result: ok\. 1 passed; 0 failed" "$test_log" \ - || { echo "::error::v5↔v7 format fence matched the wrong test set"; exit 1; } + || { echo "::error::v5↔v8 format fence matched the wrong test set"; exit 1; } test_aws_feature: name: Test omnigraph-server --features aws diff --git a/Cargo.lock b/Cargo.lock index 5acc2309..6dd096d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,7 +102,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -113,7 +113,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1571,7 +1571,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2688,7 +2688,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2868,7 +2868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5214,7 +5214,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5609,6 +5609,7 @@ dependencies = [ "omnigraph-policy", "omnigraph-storage", "proptest", + "prost", "regex", "reqwest 0.12.28", "serde", @@ -6680,7 +6681,7 @@ dependencies = [ "once_cell", "socket2 0.6.4", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7262,7 +7263,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7332,7 +7333,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7913,7 +7914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7975,7 +7976,7 @@ dependencies = [ "cfg-if 1.0.4", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8133,7 +8134,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8142,7 +8143,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8596,7 +8597,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8999,7 +9000,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/benchmarks/README.md b/benchmarks/README.md index 75c751d2..8721287e 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -17,7 +17,16 @@ cargo bench --locked -p omnigraph-engine --bench scenarios -- \ ``` Use `branch-create`, `branch-create-from`, `branch-list`, or `branch-delete`. -`--branches` counts existing siblings, excluding `main` and the delete target; + +The same setup/operation/verify owner also provides: + +- `branch-pointer-adopt-lazy`: fast-forward a changed source into a lazy named target. +- `branch-pointer-adopt-owned`: fast-forward a descendant source into a named target that already owns a table fork. +- `branch-first-write`: append one row to a target that inherits its source's table fork. +- `branch-cleanup`: collect retired forks while preserving a native table parent required by a live child. + +These four scenarios verify complete rows, exact published table pins and physical table refs outside the timed operation. Pointer scenarios require zero instrumented table writes and verify a later isolated target write in the separate verification child. Cleanup creates collectible work even with `--retired-branches 0`; its effective collectible count is recorded separately. The age/cache/layout controls remain available, and the full-row fixtures are bounded to 16,384 rows, 16 dimensions and eight branches/tables. A larger cold fixture with a one-row delta can use `--rows 4096 --dims 4`; warm/compacted controls retain their smaller limits. Setup and the verification child's isolation write are never included in operation latency. +`--branches` counts existing siblings, excluding `main` and scenario-specific targets or cleanup parent/child branches; `--tables` counts populated tables. One table contains `--rows` vectors of `--dims` dimensions, and each remaining table contains one scalar row. Create-from uses a named source with data distinct from main. The delete @@ -28,8 +37,8 @@ Each repetition prepares a fresh fixture, measures one public operation, and verifies the resulting branch registry and pinned table views in separate processes. Operation time excludes graph open. Operation-process peak RSS includes runtime initialization and graph open, but excludes setup and final -verification. Delete reports acknowledgement and completed reclamation -separately. Compare identical parameters, builds, and machines; these JSONL +verification. Delete leaves table forks for explicit cleanup and reports no +reclaim wait. Compare identical parameters, builds, and machines; these JSONL records are diagnostic evidence and do not enter the durable archive. `fenced-adopt-all-new` measures an insertion-only merge into an unchanged @@ -47,8 +56,9 @@ to a single-path throughput sample. forking, then restores its original embedding. The accepted row count and content stay fixed while graph commits, table versions, and deletion history accumulate. N defaults to zero and must be even, at most 256. -`--retired-branches N` creates, writes, deletes, and awaits reclamation of N -temporary branches before the measured workload, at most 32. This is a +`--retired-branches N` creates, writes and deletes N temporary graph branches +before the measured workload, at most 32. Their table forks remain until +explicit cleanup. This is a separate churn dimension; retired branches do not add reachable main history. Both options apply to branch controls and `general-merge-updates` only. The setup records and checks actual history growth and content restoration. @@ -114,8 +124,9 @@ memory pool, and three-second pauses between points. These are recorded runtime settings, not a hard CPU or process-memory cap. Each point has a 180-second whole-process watchdog. All child phases and fixture parameters must verify before a sample is accepted. Counters cover foreground operation -I/O; deferred reclaim I/O is outside their task-local scope. Open time and -delete completion time remain separate from acknowledgement latency. +I/O through instrumented handles, including the exported table-write count; +they do not represent all storage requests or deletes. Explicit cleanup is +a separate scenario. Open time remains separate from operation latency. Explicit age runs of create/create-from additionally time the first accepted snapshot, pinned opens, and one payload row per inherited table after the fork. Those reads have separate counters and do not enter acknowledgement or diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index 49dd5c0f..c217826c 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -1164,9 +1164,6 @@ impl GraphClient { let db = Self::open_embedded(uri).await?; let actor = actor.as_deref(); db.branch_delete_as(name, actor).await?; - // The process exits right after this call; join the - // background fork reclaim so it is not dropped mid-flight. - db.wait_for_fork_reclaims().await; Ok(BranchDeleteOutput { uri: uri.clone(), name: name.to_string(), @@ -1212,11 +1209,7 @@ impl GraphClient { // composition sites against drift). let (branch_deleted, branch_delete_error) = if delete_branch { match db.branch_delete_as(source, actor).await { - Ok(()) => { - // Same process-exit reasoning as branch_delete's join. - db.wait_for_fork_reclaims().await; - (Some(true), None) - } + Ok(()) => (Some(true), None), Err(err) => (Some(false), Some(err.to_string())), } } else { diff --git a/crates/omnigraph-cli/src/upgrade.rs b/crates/omnigraph-cli/src/upgrade.rs index 05f247db..8264fbc9 100644 --- a/crates/omnigraph-cli/src/upgrade.rs +++ b/crates/omnigraph-cli/src/upgrade.rs @@ -116,6 +116,9 @@ fn print_human(report: &omnigraph::db::UpgradeReport) -> Result<()> { .validation_bytes .map_or_else(|| "unknown".into(), |v| v.to_string()) ); + for check in &report.work.deferred_checks { + println!("validation deferred until the preceding conversion completes: {check}"); + } for exclusion in &report.work.external_blob_exclusions { println!("external bytes excluded from preservation: {exclusion}"); } @@ -152,12 +155,12 @@ mod tests { "graph.omni", "--check", "--to-format", - "7", + "8", "--json", ]) .unwrap(); assert!( - matches!(&cli.command, Command::Upgrade { uri: Some(uri), check: true, to_format: Some(7), json: true } if uri == "graph.omni") + matches!(&cli.command, Command::Upgrade { uri: Some(uri), check: true, to_format: Some(8), json: true } if uri == "graph.omni") ); assert_eq!( planes::command_capability(&cli.command), diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs index 53fa9f05..bc343221 100644 --- a/crates/omnigraph-cli/tests/cli_data.rs +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -2888,7 +2888,7 @@ fn branch_merge_supports_explicit_target() { } #[test] -fn branch_merge_delete_branch_deletes_source() { +fn branch_merge_delete_branch_retires_parent_with_live_child() { let temp = tempdir().unwrap(); let graph = graph_path(temp.path()); init_graph(&graph); @@ -2921,6 +2921,17 @@ fn branch_merge_delete_branch_deletes_source() { .arg(&graph), ); + output_success( + cli() + .arg("branch") + .arg("create") + .arg("--uri") + .arg(&graph) + .arg("--from") + .arg("feature") + .arg("feature-child"), + ); + let merge_output = output_success( cli() .arg("branch") @@ -2945,7 +2956,10 @@ fn branch_merge_delete_branch_deletes_source() { .arg("--json"), ); let list_payload: Value = serde_json::from_slice(&list_output.stdout).unwrap(); - assert_eq!(list_payload["branches"], serde_json::json!(["main"])); + assert_eq!( + list_payload["branches"], + serde_json::json!(["feature-child", "main"]) + ); } #[test] @@ -2955,28 +2969,25 @@ fn branch_merge_delete_branch_refusal_warns_and_exits_zero() { init_graph(&graph); load_fixture(&graph); - for (from, name) in [("main", "feature"), ("feature", "feature-child")] { - output_success( - cli() - .arg("branch") - .arg("create") - .arg("--uri") - .arg(&graph) - .arg("--from") - .arg(from) - .arg(name), - ); - } + output_success( + cli() + .arg("branch") + .arg("create") + .arg("--uri") + .arg(&graph) + .arg("--from") + .arg("main") + .arg("feature"), + ); - // `feature` has a dependent descendant, so the post-merge deletion is - // refused — the merge (already_up_to_date: deletion is still attempted) - // must succeed with exit code 0 and a stderr warning. let merge_output = output_success( cli() .arg("branch") .arg("merge") .arg("--uri") .arg(&graph) + .arg("main") + .arg("--into") .arg("feature") .arg("--delete-branch") .arg("--json"), @@ -2988,10 +2999,10 @@ fn branch_merge_delete_branch_refusal_warns_and_exits_zero() { merge_payload["branch_delete_error"] .as_str() .unwrap() - .contains("feature-child") + .contains("cannot delete branch 'main'") ); let stderr = String::from_utf8_lossy(&merge_output.stderr); - assert!(stderr.contains("could not delete branch 'feature'")); + assert!(stderr.contains("could not delete branch 'main'")); let list_output = output_success( cli() @@ -3004,7 +3015,7 @@ fn branch_merge_delete_branch_refusal_warns_and_exits_zero() { let list_payload: Value = serde_json::from_slice(&list_output.stdout).unwrap(); assert_eq!( list_payload["branches"], - serde_json::json!(["feature", "feature-child", "main"]) + serde_json::json!(["feature", "main"]) ); } diff --git a/crates/omnigraph-cli/tests/crossversion_upgrade.rs b/crates/omnigraph-cli/tests/crossversion_upgrade.rs index 8661b97d..be9291ee 100644 --- a/crates/omnigraph-cli/tests/crossversion_upgrade.rs +++ b/crates/omnigraph-cli/tests/crossversion_upgrade.rs @@ -12,14 +12,14 @@ //! The v3 case uses `OMNIGRAPH_OLD_BIN` (0.7.2), and the v4 case uses //! `OMNIGRAPH_PREVIOUS_BIN` (0.8.1). The immediate-predecessor v5 case uses //! `OMNIGRAPH_V5_BIN` (built from the final internal-v5 commit) and proves both -//! directions of the v5/v6 format fence. Each case skips only when its variable +//! directions of the v5/v8 format fence. Each case skips only when its variable //! is unset; a set but invalid path fails loudly. //! `OMNIGRAPH_V09_BIN` selects the released v0.9 CLI for the end-to-end //! journey of a fully exercised v6 graph — branches, edges, vectors, //! full-text and blobs — which the current binary refuses and which is //! rebuilt from a 0.9 export. //! `OMNIGRAPH_V6_BIN` (the released 0.10.x CLI) proves both directions of the -//! v6/v7 fence (RFC 0062). +//! v6/v8 fence (RFC 0062 registration clock and RFC 0042 native-ref retirement metadata). mod support; @@ -190,16 +190,16 @@ fn assert_exported_blob_fidelity(label: &str, original: &[u8], rebuilt: &[u8]) { /// Format v6 activates RFC-023 by installing exactly `id` as the unenforced /// Lance primary key on every graph dataset. Assert the rebuilt image crossed -/// that physical boundary, not only that its stamp changed; v7 (RFC 0062) preserves it. -fn assert_v6_graph_datasets_use_exact_id_pk(graph: &Path) { +/// that physical boundary, which the current storage format preserves. +fn assert_rebuilt_graph_datasets_use_exact_id_pk(graph: &Path) { tokio::runtime::Runtime::new().unwrap().block_on(async { let db = Omnigraph::open(graph.to_string_lossy().as_ref()) .await - .expect("open rebuilt v6 graph"); + .expect("open rebuilt v8 graph"); let snapshot = db .snapshot_of(ReadTarget::branch("main")) .await - .expect("open rebuilt v6 main snapshot"); + .expect("open rebuilt v8 main snapshot"); let type_keys = snapshot .datasets() .filter(|entry| { @@ -207,12 +207,12 @@ fn assert_v6_graph_datasets_use_exact_id_pk(graph: &Path) { }) .map(|entry| entry.type_key.clone()) .collect::>(); - assert!(!type_keys.is_empty(), "rebuilt v6 graph has no graph datasets"); + assert!(!type_keys.is_empty(), "rebuilt v8 graph has no graph datasets"); for type_key in type_keys { let dataset = snapshot .open_dataset(&type_key) .await - .unwrap_or_else(|error| panic!("open rebuilt v6 dataset {type_key}: {error}")); + .unwrap_or_else(|error| panic!("open rebuilt v8 dataset {type_key}: {error}")); let primary_key = dataset .schema() .unenforced_primary_key() @@ -222,21 +222,21 @@ fn assert_v6_graph_datasets_use_exact_id_pk(graph: &Path) { assert_eq!( primary_key, ["id"], - "rebuilt v6 dataset {type_key} must declare exactly `id` as its Lance unenforced primary key", + "rebuilt v8 dataset {type_key} must declare exactly `id` as its Lance unenforced primary key", ); } }); } -fn assert_v6_graph_datasets_empty(graph: &Path) { +fn assert_rebuilt_graph_datasets_empty(graph: &Path) { tokio::runtime::Runtime::new().unwrap().block_on(async { let db = Omnigraph::open(graph.to_string_lossy().as_ref()) .await - .expect("open rejected-import v6 graph"); + .expect("open rejected-import v8 graph"); let snapshot = db .snapshot_of(ReadTarget::branch("main")) .await - .expect("open rejected-import v6 main snapshot"); + .expect("open rejected-import v8 main snapshot"); for entry in snapshot.datasets().filter(|entry| { entry.type_key.starts_with("node:") || entry.type_key.starts_with("edge:") }) { @@ -256,11 +256,11 @@ fn assert_v6_graph_datasets_empty(graph: &Path) { }); } -fn assert_v6_blob_bytes(graph: &Path, expected: &[u8]) { +fn assert_rebuilt_blob_bytes(graph: &Path, expected: &[u8]) { tokio::runtime::Runtime::new().unwrap().block_on(async { let db = Omnigraph::open(graph.to_string_lossy().as_ref()) .await - .expect("open rebuilt v6 graph for blob read"); + .expect("open rebuilt v8 graph for blob read"); let blob = db .read_blob_at( ReadTarget::branch("main"), @@ -274,7 +274,7 @@ fn assert_v6_blob_bytes(graph: &Path, expected: &[u8]) { .await .expect("open rebuilt blob"); let BlobContent::Managed { reader, .. } = blob.content else { - panic!("v5 → v6 rebuild must produce managed Blob content"); + panic!("v5 → v8 rebuild must produce managed Blob content"); }; let bytes = reader .read_range(0..reader.len()) @@ -283,7 +283,7 @@ fn assert_v6_blob_bytes(graph: &Path, expected: &[u8]) { assert_eq!( &bytes[..], expected, - "v5 → v6 rebuild must preserve exact blob bytes", + "v5 → v8 rebuild must preserve exact blob bytes", ); }); } @@ -374,12 +374,12 @@ fn current_binary_refuses_and_rebuilds_a_genuine_v3_graph() { // 5. Round-trip fidelity: re-export with the current binary and compare. let reexport = output_success(cli().arg("export").arg(&new_graph)); - assert_export_fidelity("v3 → v6", &export.stdout, &reexport.stdout); - assert_v6_graph_datasets_use_exact_id_pk(&new_graph); + assert_export_fidelity("v3 → v8", &export.stdout, &reexport.stdout); + assert_rebuilt_graph_datasets_use_exact_id_pk(&new_graph); } #[test] -fn current_v7_refuses_and_rebuilds_genuine_v4_and_v4_refuses_v7() { +fn current_v8_refuses_and_rebuilds_genuine_v4_and_v4_refuses_v8() { let Some(previous) = previous_bin() else { eprintln!( "skipping immediate-predecessor upgrade test: OMNIGRAPH_PREVIOUS_BIN is not set to a 0.8.1 binary" @@ -429,7 +429,7 @@ fn current_v7_refuses_and_rebuilds_genuine_v4_and_v4_refuses_v7() { assert!(stderr.contains("0.8.x"), "got: {stderr}"); assert!(stderr.contains("export"), "got: {stderr}"); - let new_graph = temp.path().join("new-v6-from-v4.omni"); + let new_graph = temp.path().join("new-v8-from-v4.omni"); output_success( cli() .arg("init") @@ -447,13 +447,13 @@ fn current_v7_refuses_and_rebuilds_genuine_v4_and_v4_refuses_v7() { .arg(&new_graph), ); let reexport = output_success(cli().arg("export").arg(&new_graph)); - assert_export_fidelity("v4 → v6", &export.stdout, &reexport.stdout); - assert_v6_graph_datasets_use_exact_id_pk(&new_graph); + assert_export_fidelity("v4 → v8", &export.stdout, &reexport.stdout); + assert_rebuilt_graph_datasets_use_exact_id_pk(&new_graph); let reverse = run_old(&previous, &["snapshot", new_graph.to_str().unwrap()]); assert!( !reverse.status.success(), - "a v4 binary must refuse a genuine v6 graph" + "a v4 binary must refuse a genuine v8 graph" ); let reverse_stderr = String::from_utf8_lossy(&reverse.stderr); assert!( @@ -465,7 +465,7 @@ fn current_v7_refuses_and_rebuilds_genuine_v4_and_v4_refuses_v7() { } #[test] -fn current_v7_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v7() { +fn current_v8_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v8() { let Some(v5) = v5_bin() else { eprintln!( "skipping immediate-predecessor v5 upgrade test: OMNIGRAPH_V5_BIN is not set to a final internal-v5 binary" @@ -499,9 +499,6 @@ fn current_v7_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v7() { std::fs::write(&data, search_data).unwrap(); let v5_uri = v5_graph.to_str().unwrap(); - // Mint the predecessor image with the predecessor binary. This exercises - // the genuine v5 manifest/schema-identity layout, not a v6 graph whose - // internal-schema stamp was edited after creation. assert_ok( "v5 init", &run_old(&v5, &["init", "--schema", schema.to_str().unwrap(), v5_uri]), @@ -562,7 +559,7 @@ fn current_v7_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v7() { let duplicate_jsonl = temp.path().join("v5-duplicate-id.jsonl"); std::fs::write(&duplicate_jsonl, duplicate_export).unwrap(); - let rejected_graph = temp.path().join("rejected-v6-from-v5.omni"); + let rejected_graph = temp.path().join("rejected-v8-from-v5.omni"); output_success( cli() .arg("init") @@ -584,7 +581,7 @@ fn current_v7_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v7() { rejected_stderr.contains("@unique violation") && rejected_stderr.contains("ml-intro"), "duplicate-id rebuild import must fail loudly with the duplicate key, got: {rejected_stderr}", ); - assert_v6_graph_datasets_empty(&rejected_graph); + assert_rebuilt_graph_datasets_empty(&rejected_graph); let source_after_rejection = run_old(&v5, &["export", v5_uri]); assert_ok( "v5 export after rejected target import", @@ -596,13 +593,13 @@ fn current_v7_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v7() { "a rejected target import must leave the old source root untouched", ); - let v6_graph = temp.path().join("new-v6-from-v5.omni"); + let v8_graph = temp.path().join("new-v8-from-v5.omni"); output_success( cli() .arg("init") .arg("--schema") .arg(&schema) - .arg(&v6_graph), + .arg(&v8_graph), ); output_success( cli() @@ -611,32 +608,32 @@ fn current_v7_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v7() { .arg("overwrite") .arg("--data") .arg(&jsonl) - .arg(&v6_graph), + .arg(&v8_graph), ); - let reexport = output_success(cli().arg("export").arg(&v6_graph)); - assert_export_fidelity("v5 → v6", &export.stdout, &reexport.stdout); - assert_exported_blob_fidelity("v5 → v6", &export.stdout, &reexport.stdout); - assert_v6_graph_datasets_use_exact_id_pk(&v6_graph); - assert_v6_blob_bytes(&v6_graph, &[0, 1, 2, 3, 255]); + let reexport = output_success(cli().arg("export").arg(&v8_graph)); + assert_export_fidelity("v5 → v8", &export.stdout, &reexport.stdout); + assert_exported_blob_fidelity("v5 → v8", &export.stdout, &reexport.stdout); + assert_rebuilt_graph_datasets_use_exact_id_pk(&v8_graph); + assert_rebuilt_blob_bytes(&v8_graph, &[0, 1, 2, 3, 255]); // The fence is bidirectional: a predecessor writer cannot accidentally // open and mutate the new PK-bearing format either. - let reverse = run_old(&v5, &["snapshot", v6_graph.to_str().unwrap()]); + let reverse = run_old(&v5, &["snapshot", v8_graph.to_str().unwrap()]); assert!( !reverse.status.success(), - "a v5 binary must refuse a genuine v6 graph", + "a v5 binary must refuse a genuine v8 graph", ); let reverse_stderr = String::from_utf8_lossy(&reverse.stderr); assert!( reverse_stderr.contains("upgrade omnigraph") || reverse_stderr.contains("newer") || reverse_stderr.contains("expects v5"), - "unexpected v5→v6 reverse-refusal message: {reverse_stderr}", + "unexpected v5→v8 reverse-refusal message: {reverse_stderr}", ); } #[test] -fn current_v7_refuses_and_rebuilds_genuine_v6_and_v6_refuses_v7() { +fn current_v8_refuses_and_rebuilds_genuine_v6_and_v6_refuses_v8() { let Some(v6) = v6_bin() else { eprintln!( "skipping immediate-predecessor v6 upgrade test: OMNIGRAPH_V6_BIN is not set to a released 0.10.x binary" @@ -707,13 +704,13 @@ fn current_v7_refuses_and_rebuilds_genuine_v6_and_v6_refuses_v7() { "v6 refusal must direct the operator to export/import rebuild, got: {stderr}", ); - let v7_graph = temp.path().join("new-v7-from-v6.omni"); + let v8_graph = temp.path().join("new-v8-from-v6.omni"); output_success( cli() .arg("init") .arg("--schema") .arg(&schema) - .arg(&v7_graph), + .arg(&v8_graph), ); output_success( cli() @@ -722,30 +719,30 @@ fn current_v7_refuses_and_rebuilds_genuine_v6_and_v6_refuses_v7() { .arg("overwrite") .arg("--data") .arg(&jsonl) - .arg(&v7_graph), + .arg(&v8_graph), ); - let reexport = output_success(cli().arg("export").arg(&v7_graph)); - assert_export_fidelity("v6 → v7", &export.stdout, &reexport.stdout); - assert_exported_blob_fidelity("v6 → v7", &export.stdout, &reexport.stdout); - assert_v6_graph_datasets_use_exact_id_pk(&v7_graph); - assert_v6_blob_bytes(&v7_graph, &[0, 1, 2, 3, 255]); + let reexport = output_success(cli().arg("export").arg(&v8_graph)); + assert_export_fidelity("v6 → v8", &export.stdout, &reexport.stdout); + assert_exported_blob_fidelity("v6 → v8", &export.stdout, &reexport.stdout); + assert_rebuilt_graph_datasets_use_exact_id_pk(&v8_graph); + assert_rebuilt_blob_bytes(&v8_graph, &[0, 1, 2, 3, 255]); - let reverse = run_old(&v6, &["snapshot", v7_graph.to_str().unwrap()]); + let reverse = run_old(&v6, &["snapshot", v8_graph.to_str().unwrap()]); assert!( !reverse.status.success(), - "a v6 binary must refuse a genuine v7 graph", + "a v6 binary must refuse a genuine v8 graph", ); let reverse_stderr = String::from_utf8_lossy(&reverse.stderr); assert!( reverse_stderr.contains("upgrade omnigraph") || reverse_stderr.contains("newer") || reverse_stderr.contains("expects v6"), - "unexpected v6→v7 reverse-refusal message: {reverse_stderr}", + "unexpected v6→v8 reverse-refusal message: {reverse_stderr}", ); } #[test] -fn current_v7_refuses_and_rebuilds_genuine_v09_graph_end_to_end() { +fn current_v8_refuses_and_rebuilds_genuine_v09_graph_end_to_end() { use serde_json::json; use std::fs; use support::{parse_stdout_json, resolved_snapshot_id, spawn_server_with_cluster}; @@ -1166,7 +1163,7 @@ query revise($body: String) { update Doc set { body: $body } where slug = "dl-ba let reverse = run_old(&old, &["snapshot", rebuilt_uri]); assert!( !reverse.status.success(), - "a 0.9 binary must refuse a genuine v7 graph", + "a 0.9 binary must refuse a genuine v8 graph", ); let reverse_stderr = String::from_utf8_lossy(&reverse.stderr); assert!( @@ -1389,23 +1386,33 @@ query vectors($q: Vector(4)) { uri, "--check", "--to-format", - "7", + "8", "--json", ]))); assert_eq!(check["outcome"], "check_passed"); + assert_eq!(check["observed_format"], 6); + assert_eq!(check["target_format"], 8); + assert_eq!(check["target_defaulted"], false); + assert_eq!(check["route"].as_array().unwrap().len(), 2); + assert!( + !check["work"]["deferred_checks"] + .as_array() + .unwrap() + .is_empty() + ); assert_eq!( graph_files(&graph), before, "--check and refused open must leave every source byte unchanged" ); - let upgraded = support::parse_stdout_json(&output_success(cli().args([ - "upgrade", - uri, - "--to-format", - "7", - "--json", - ]))); + let upgraded = + support::parse_stdout_json(&output_success(cli().args(["upgrade", uri, "--json"]))); assert_eq!(upgraded["outcome"], "completed"); + assert_eq!(upgraded["target_format"], 8); + assert_eq!(upgraded["target_defaulted"], true); + assert_eq!(upgraded["completed_handlers"].as_array().unwrap().len(), 2); + assert_eq!(upgraded["work"]["payload_bytes_copied"], 0); + assert_eq!(upgraded["work"]["payload_bytes_rewritten"], 0); let after = graph_files(&graph); let schema_identity = Path::new("_schema.ir.json"); assert!(before.contains_key(schema_identity)); @@ -1436,6 +1443,18 @@ query vectors($q: Vector(4)) { let report = support::parse_stdout_json(&output_success(&mut command)); assert_eq!(report["outcome"], "already_current"); assert_eq!(graph_files(&graph), after, "rerun must be effect-free"); + let mut downgrade = cli(); + downgrade.args(["upgrade", uri, "--to-format", "7", "--json"]); + if check_mode { + downgrade.arg("--check"); + } + let refused = support::parse_stdout_json(&output_failure(&mut downgrade)); + assert_ne!(refused["outcome"], "completed"); + assert_eq!( + graph_files(&graph), + after, + "downgrade refusal must be effect-free" + ); } assert!( !run_old(old, &["snapshot", uri]).status.success(), diff --git a/crates/omnigraph-dst/cost_table.txt b/crates/omnigraph-dst/cost_table.txt index 91c97bd7..66a6e2f6 100644 --- a/crates/omnigraph-dst/cost_table.txt +++ b/crates/omnigraph-dst/cost_table.txt @@ -3,8 +3,8 @@ AddFriend a.exists calls=16 AddFriend a.get calls=28 AddFriend a.list calls=8 AddFriend a.put calls=8 -AddFriend l.get calls=396 -AddFriend l.list calls=76 +AddFriend l.get calls=368 +AddFriend l.list calls=65 AddFriend l.put calls=30 BranchCreate a.exists calls=12 BranchCreate a.get calls=18 @@ -15,19 +15,19 @@ BranchCreate l.put calls=6 BranchDelete a.exists calls=6 BranchDelete a.get calls=9 BranchDelete a.list calls=2 -BranchDelete l.delete calls=3 -BranchDelete l.get calls=68 -BranchDelete l.list calls=14 +BranchDelete l.get calls=67 +BranchDelete l.list calls=8 +BranchDelete l.put calls=1 Cleanup a.exists calls=28 Cleanup a.get calls=44 -Cleanup a.list calls=4 -Cleanup l.delete calls=21 -Cleanup l.get calls=209 -Cleanup l.list calls=134 +Cleanup a.list calls=6 +Cleanup l.delete calls=24 +Cleanup l.get calls=345 +Cleanup l.list calls=168 DeletePerson a.exists calls=6 DeletePerson a.get calls=12 DeletePerson a.list calls=3 -DeletePerson l.get calls=3 +DeletePerson l.get calls=9 DeletePerson l.list calls=15 EnsureIndices a.exists calls=4 EnsureIndices a.get calls=7 @@ -39,8 +39,8 @@ InsertLegacy a.exists calls=12 InsertLegacy a.get calls=21 InsertLegacy a.list calls=6 InsertLegacy a.put calls=6 -InsertLegacy l.get calls=278 -InsertLegacy l.list calls=59 +InsertLegacy l.get calls=246 +InsertLegacy l.list calls=48 InsertLegacy l.put calls=24 InsertV a.delete calls=1 InsertV a.exists calls=4 @@ -63,13 +63,13 @@ RemoveFriendshipsFrom a.exists calls=14 RemoveFriendshipsFrom a.get calls=27 RemoveFriendshipsFrom a.list calls=7 RemoveFriendshipsFrom a.put calls=2 -RemoveFriendshipsFrom l.get calls=114 +RemoveFriendshipsFrom l.get calls=128 RemoveFriendshipsFrom l.list calls=43 RemoveFriendshipsFrom l.put calls=6 UpdateV a.exists calls=8 UpdateV a.get calls=16 UpdateV a.list calls=4 -UpdateV l.get calls=4 +UpdateV l.get calls=10 UpdateV l.list calls=20 _audit a.delete calls=1 _audit a.exists calls=79 @@ -99,5 +99,5 @@ _setup l.put calls=27 _verify a.exists calls=376 _verify a.get calls=601 _verify a.list calls=13 -_verify l.get calls=2074 +_verify l.get calls=2078 _verify l.list calls=242 diff --git a/crates/omnigraph-dst/src/catalog.rs b/crates/omnigraph-dst/src/catalog.rs index 40db1028..22b65d88 100644 --- a/crates/omnigraph-dst/src/catalog.rs +++ b/crates/omnigraph-dst/src/catalog.rs @@ -1,6 +1,6 @@ //! The full crash-window catalog for the hunt //! (`dst_hunt_crash_window_sweep`): the engine's `src/failpoints.rs` -//! name set, 72 windows at the pinned engine version. A window added to +//! name set, 70 windows at the pinned engine version. A window added to //! the engine enters here as never-reached until its workload exists. //! //! Kept honest by `catalog_names_are_engine_failpoints` below: every @@ -10,11 +10,10 @@ //! `names::*` consts directly would be stronger still; the guard covers //! the failure mode until then.) -pub const CRASH_WINDOWS: [&str; 72] = [ +pub const CRASH_WINDOWS: [&str; 70] = [ "blob_read.post_capture", "branch_control.post_recovery_barrier", "branch_create.post_native", - "branch_delete.before_table_cleanup", "branch_delete.post_native", "branch_delete.post_table_gates", "branch_merge.adopt_after_append_pre_upsert", @@ -39,7 +38,6 @@ pub const CRASH_WINDOWS: [&str; 72] = [ "ensure_indices.post_stage_pre_commit_btree", "ensure_indices.post_table_effect", "fork.before_classify", - "fork.before_reclaim", "fork.post_create_pre_open", "graph_publish.after_manifest_commit", "graph_publish.before_commit_append", diff --git a/crates/omnigraph-dst/src/harness.rs b/crates/omnigraph-dst/src/harness.rs index ee6efffb..67610791 100644 --- a/crates/omnigraph-dst/src/harness.rs +++ b/crates/omnigraph-dst/src/harness.rs @@ -673,8 +673,8 @@ pub struct Scenario { /// for the hunt's per-op attribution), this answers the census question /// "did ANY execution in this universe walk the line" — and it composes /// with a REAL `crash_on_match` setup crash, which is how the - /// orphan-reclaim windows (classify.fresh_read, fork.before_reclaim) - /// and the recovery.* internals get their preconditions built. + /// recovery.* internals get their preconditions built. Cleanup workloads + /// obtain unused forks through ordinary branch deletion. pub probe_window: Option<&'static str>, /// SENSITIVITY KNOB (test-only red proof): force /// the maintenance-obligation RERUN to fail through a real engine @@ -779,13 +779,7 @@ enum Milestone { const MILESTONE_BRANCH: &str = "mstone"; -/// The milestone sequence for a target window: window-specific recipes first -/// (preconditions named from the engine sites), then the family -/// default. Empty = not milestone territory (schema is quarantined; init/open -/// are owned by birth universes). The recovery.* internals and the -/// orphan-reclaim windows (classify.fresh_read, fork.before_reclaim) DO get -/// steps here — theirs build the PRIMARY crash's precondition; the census -/// pairs them with a real setup crash (`census_setup` in the instrument). +/// Return the setup milestones that reach a target failpoint window. fn milestone_steps(window: &str) -> Vec { use Milestone::*; // Window-specific recipes (2026-08-12). @@ -800,26 +794,9 @@ fn milestone_steps(window: &str) -> Vec { | "fork.post_create_pre_open" => { return vec![EnsureBranch, DataOnBranch]; } - // Orphan-ref territory: the census crashes the branch delete at - // `before_table_cleanup`, whose injected failure the engine SWALLOWS - // (branch gone, per-table fork refs leak — omnigraph.rs's own doc - // names the cleanup reconciler as the backstop). Then: - // - a cleanup walks the leaked refs (reconcile_fork + the - // classify fresh-authority read); - // - re-creating the branch and writing to it collides with the - // leaked ref on the write path (reclaim_orphaned_fork_and_refork). "classify.fresh_read" | "cleanup.reconcile_fork" => { return vec![EnsureBranch, DataOnBranch, DeleteBranch, CleanupMain]; } - "fork.before_reclaim" => { - return vec![ - EnsureBranch, - DataOnBranch, - DeleteBranch, - EnsureBranch, - DataOnBranch, - ]; - } // Merge whose delta carries a DELETE — the rewrite-with-deletes // route. The MutateMain runs BEFORE the branch is cut so the branch // base holds an edge-free person the branch can delete without the diff --git a/crates/omnigraph-dst/tests/scenarios.rs b/crates/omnigraph-dst/tests/scenarios.rs index 68d93e6f..6e78eb7c 100644 --- a/crates/omnigraph-dst/tests/scenarios.rs +++ b/crates/omnigraph-dst/tests/scenarios.rs @@ -1278,29 +1278,9 @@ fn assert_resolution_row(report: &omnigraph_dst::harness::UniverseReport, seed: ); } -/// ISSUE #554 CATCH: a lance-realm write fault fails one mutation between -/// arming its v9 recovery sidecar and its table commit; the stranded Armed -/// intent is effect-free, yet the write-entry heal defers it and every -/// later write on the live handle is refused with the same operation id. -/// With the keep-serving budget armed this test is RED at engine HEAD -/// (detector=Store(Session)/LiveWriteAvailability) and goes green when the -/// live heal retires provably effect-free Armed mutation/load intents. -/// -/// PANEL, not a single pin: lance-realm universes sit outside the strict -/// replay envelope (pool threads race the entropy shim), so any ONE seed's -/// strand can evaporate or reappear with process context — observed in -/// both directions on seed 0 even at `error_pct: 80`. Every member is a -/// verified flip seed, and the panel verdict is robust: RED if ANY member -/// wedges, green only when ALL heal — jitter would have to erase every -/// member at once to fake a pass, and the shape assert catches even that -/// as a loud wrong-reason failure. -/// -/// Re-pinning after upstream drift is a two-step protocol: run -/// `dst_keep_serving_wedge_seed_search` (deliberately broader parameters — -/// ops 30, `error_pct: 15` — to survey the wedge population), then screen -/// its wedge seeds at THIS test's parameters against the engine fix: a -/// panel member must strand-and-wedge (or strand harmlessly) at HEAD and -/// heal under the fix. +/// Exercises live retirement of effect-free Armed mutation/load intents with a short fault panel. +/// Seed 21 strands Optimize, which lacks exact transaction ownership and requires Full recovery. +/// Every eligible seed must stay available, and at least one must exercise deferred recovery. #[test] #[serial] fn dst_keep_serving_wedge_issue_554() { @@ -1317,7 +1297,7 @@ fn dst_keep_serving_wedge_issue_554() { // (effectful / excluded-class intents the engine correctly refuses to // retire live — the detector's precision boundary, observed in the // wild; not panel material). - const PANEL: [u64; 14] = [0, 4, 10, 11, 14, 15, 17, 20, 21, 22, 23, 25, 26, 28]; + const PANEL: [u64; 13] = [0, 4, 10, 11, 14, 15, 17, 20, 22, 23, 25, 26, 28]; let mut wedged: Vec = Vec::new(); let mut defer_rows = 0usize; for seed in PANEL { @@ -1501,7 +1481,7 @@ fn dst_ack_loss_bite_and_replay() { #[test] #[serial] fn dst_ack_loss_client_retry() { - for (seed, fault_seed, ack_loss_pct, ops) in [(226251, 23303853, 15, 1), (79, 7900, 20, 30)] { + for (seed, fault_seed, ack_loss_pct, ops) in [(226251, 23303853, 15, 1), (79, 7912, 20, 30)] { let sc = Scenario { seed, ops, @@ -1754,28 +1734,16 @@ fn dst_stale_sidecar_bricks_recovery() { println!("dst sidecar-weather finding pin: stale-sidecar recovery brick reproduced: {a}"); } -/// CORRUPT-WRITE FIRST CONTACT (attended): -/// sidecar contents stored MUTATED, plus injected errors forcing deaths so -/// recovery must PARSE the garbage it wrote. The open question this -/// universe asks the engine: a corrupted sidecar met by -/// `heal_pending_sidecars_roll_forward` — detected (typed error, sidecar -/// quarantined) or swallowed or wedged? Reconcile's reopen-retry asserts -/// non-injected failures loudly ("reopen failed for a NON-injected -/// reason"), so a wedge shows as that panic naming the serde error — the -/// first red here is the likeliest find of the whole persisted-tier effort. +/// Pins consumed persisted corruption and an attributed refusal, with strict replay. +/// Malformed sidecars can stop recovery; this specimen is not a fleet availability guarantee. #[test] #[serial] fn dst_corrupt_write_first_contact() { - // Seed re-pinned 101 -> 112 (2026-09-01), for the same reason as the - // stale-sidecar pin above. Seed 112 reproduces the pinned intent - // (corrupt writes bite, recovery parses stored garbage, a detection - // row is recorded); found by the bounded 100..150 search at these - // exact parameters. let sc = Scenario { seed: 112, ops: 30, faults: Some(omnigraph_dst::harness::FaultPlan { - seed: 11200, + seed: 11204, error_pct: 12, corrupt_write_pct: 25, ..Default::default() @@ -1798,6 +1766,20 @@ fn dst_corrupt_write_first_contact() { "corrupt-write should actually bite (writes_corrupted={})", a.writes_corrupted ); + assert!( + a.persisted_consumed > 0, + "an engine read must consume persisted damage, not merely leave an unobserved write" + ); + assert!( + !a.corruption_detections.is_empty(), + "the first-contact specimen must record a refusal attributed to consumed corruption" + ); + let replay = run_universe("shared-memory://dst-s11b-corrupt-replay", &sc); + omnigraph_dst::harness::assert_strict_replay( + &a, + &replay, + "adapter-realm persisted corruption must strictly replay", + ); } /// CRASH-STATE ENUMERATION (sampled; ALICE-style crash-state @@ -2083,14 +2065,7 @@ fn dst_sensitivity_maintenance_rerun_failure_is_red() { /// post_finalize = any recovery pass for the list/audit steps). fn census_setup(window: &'static str) -> Option<(&'static str, usize)> { match window { - // Orphan-ref manufacture: the branch delete's post-flip table cleanup - // SWALLOWS injected failures (branch gone, fork refs leak; the engine - // doc names the cleanup reconciler as the backstop) — the leaked refs - // are then walked by cleanup (reconcile_fork, classify) or collided - // with on a re-created branch's first write (before_reclaim). - "classify.fresh_read" | "cleanup.reconcile_fork" | "fork.before_reclaim" => { - Some(("branch_delete.before_table_cleanup", 0)) - } + "classify.fresh_read" | "cleanup.reconcile_fork" => None, "recovery.before_roll_forward_publish" => { Some(("branch_merge.post_phase_b_pre_manifest_commit", 0)) } diff --git a/crates/omnigraph-gqt/cases/replacement_branch_after_detached_lineage.gqt b/crates/omnigraph-gqt/cases/branch_merge_after_owner_reforks.gqt similarity index 51% rename from crates/omnigraph-gqt/cases/replacement_branch_after_detached_lineage.gqt rename to crates/omnigraph-gqt/cases/branch_merge_after_owner_reforks.gqt index d4adadc3..3c01c0ef 100644 --- a/crates/omnigraph-gqt/cases/replacement_branch_after_detached_lineage.gqt +++ b/crates/omnigraph-gqt/cases/branch_merge_after_owner_reforks.gqt @@ -1,10 +1,7 @@ # issue: none -# notes: The documented remedy for `detached native lineage`: create a replacement branch from -# notes: the affected branch and write there. The replacement's write succeeds, merging it back -# notes: into the affected branch is refused with the same error while the child still pins the -# notes: former fork, and the child's rows survive a restart. Reduced from -# notes: branching.rs::branch_write_refuses_detached_native_lineage_before_arming, which keeps -# notes: the storage-level facts (no recovery sidecar, the native ref identifier unchanged). +# notes: Feature writes after a pointer switch while child still borrows its former fork. +# notes: A replacement branch then writes and fast-forwards into feature. +# notes: Further writes on feature and replacement stay isolated; child retains its old rows. --- schema node Person { @@ -50,11 +47,11 @@ branch merge main into feature --- expect outcome: fast_forward --- mutate branch: feature -query feature_write_refused() { +query feature_writes_after_pointer_switch() { update Person set { age: 33 } where name = "alice" } ---- expect error: detached native lineage +--- expect affected: nodes=1 edges=0 --- mutate branch create replacement from feature @@ -63,7 +60,7 @@ branch create replacement from feature --- mutate branch: replacement query replacement_write() { - update Person set { age: 33 } where name = "alice" + update Person set { age: 34 } where name = "alice" } --- expect affected: nodes=1 edges=0 @@ -75,7 +72,7 @@ query replacement_reads_its_write() { } --- expect unordered -{"p.name":"alice","p.age":33} +{"p.name":"alice","p.age":34} --- expect shape p.name: String @@ -84,7 +81,34 @@ p.age: I32 --- mutate branch merge replacement into feature ---- expect error: detached native lineage +--- expect outcome: fast_forward + +--- query branch: feature +query feature_reads_merged_rows() { + match { $p: Person } + return { $p.name, $p.age } +} + +--- expect unordered +{"p.name":"alice","p.age":34} + +--- expect shape +p.name: String +p.age: I32 + +--- mutate branch: feature +query feature_writes_after_borrowing() { + update Person set { age: 35 } where name = "alice" +} + +--- expect affected: nodes=1 edges=0 + +--- mutate branch: replacement +query replacement_writes_independently() { + update Person set { age: 36 } where name = "alice" +} + +--- expect affected: nodes=1 edges=0 --- restart @@ -102,7 +126,33 @@ p.name: String p.age: I32 --- query branch: feature -query feature_unchanged_by_the_refusals() { +query feature_kept_its_own_write() { + match { $p: Person } + return { $p.name, $p.age } +} + +--- expect unordered +{"p.name":"alice","p.age":35} + +--- expect shape +p.name: String +p.age: I32 + +--- query branch: replacement +query replacement_kept_its_own_write() { + match { $p: Person } + return { $p.name, $p.age } +} + +--- expect unordered +{"p.name":"alice","p.age":36} + +--- expect shape +p.name: String +p.age: I32 + +--- query branch: main +query main_kept_its_own_write() { match { $p: Person } return { $p.name, $p.age } } diff --git a/crates/omnigraph-gqt/cases/empty_merge_preserves_lazy_child_first_write.gqt b/crates/omnigraph-gqt/cases/empty_merge_preserves_lazy_child_first_write.gqt index 4262c712..1982dbe6 100644 --- a/crates/omnigraph-gqt/cases/empty_merge_preserves_lazy_child_first_write.gqt +++ b/crates/omnigraph-gqt/cases/empty_merge_preserves_lazy_child_first_write.gqt @@ -1,11 +1,7 @@ # issue: none -# notes: Main and parent have equal rows after a merge. Merging main back is an -# notes: empty delta that still points the parent at main's lineage; the -# notes: parent's former ref stays with child. The parent's next write must -# notes: refuse (detached native lineage) rather than recreate that ref, and -# notes: after a restart child's first write starts from its pin. The Rust owner -# notes: branching.rs::branch_merge_preserves_state_when_native_versions_differ keeps the -# notes: registration-level assertions (the pointer's native ref and version, no sidecar). +# notes: An empty merge points parent at main's lineage while child keeps the former fork. +# notes: Parent writes successfully; child's first write after restart starts from its old pin. +# notes: Both writes stay isolated across a second restart. --- schema node Person { @@ -48,7 +44,7 @@ query owner_next_write() { update Person set { age: 32 } where name = "alice" } ---- expect error: detached native lineage +--- expect affected: nodes=1 edges=0 --- restart @@ -94,6 +90,19 @@ query child_write_left_parent_unchanged() { return { $p.name, $p.age } } +--- expect unordered +{"p.name":"alice","p.age":32} + +--- expect shape +p.name: String +p.age: I32 + +--- query branch: main +query main_kept_merged_rows() { + match { $p: Person } + return { $p.name, $p.age } +} + --- expect unordered {"p.name":"alice","p.age":31} diff --git a/crates/omnigraph-gqt/cases/ff_into_owner_with_child_refuses_owner_write.gqt b/crates/omnigraph-gqt/cases/issue_683_owner_writes_after_fast_forward_with_child.gqt similarity index 59% rename from crates/omnigraph-gqt/cases/ff_into_owner_with_child_refuses_owner_write.gqt rename to crates/omnigraph-gqt/cases/issue_683_owner_writes_after_fast_forward_with_child.gqt index c4c7beb5..656c74bd 100644 --- a/crates/omnigraph-gqt/cases/ff_into_owner_with_child_refuses_owner_write.gqt +++ b/crates/omnigraph-gqt/cases/issue_683_owner_writes_after_fast_forward_with_child.gqt @@ -1,10 +1,8 @@ -# issue: none -# red_on: 2026-09-07, main @ ad425c68: reading b1 after b0 took a fast-forward from main and wrote once fails with `historical published dataset version 3 was reclaimed`. -# notes: The Steps block of https://github.com/ModernRelay/omnigraph/issues/683 verbatim; this case -# notes: pins the refusal, not a fix. The fast-forward from main points b0 -# notes: at main's lineage and leaves b0's former ref to b1. b0's next write refuses (detached -# notes: native lineage) instead of recreating that ref and destroying b1's rows; b1 keeps a and -# notes: n1 across a restart, b0 reads main's lineage. +# red_on: 5c4ca700d9dc5470bac4fd8d6a7f534699a2b137 +# issue: 683 +# notes: After main fast-forwards into b0, b0 writes on a fresh table fork. +# notes: The lazy child b1 keeps its former b0 rows across a restart; +# notes: the new b0 write remains isolated from both b1 and main. --- schema node Person { @@ -53,7 +51,7 @@ query b0_n2() { insert Person { name: "n2" } } ---- expect error: detached native lineage +--- expect affected: nodes=1 edges=0 --- restart @@ -71,7 +69,22 @@ query b1_persons() { p.name: String --- query branch: b0 -query b0_reads_main_lineage() { +query b0_reads_its_fresh_fork() { + match { $p: Person } + return { $p.name } +} + +--- expect unordered +{"p.name":"a"} +{"p.name":"n1"} +{"p.name":"n0"} +{"p.name":"n2"} + +--- expect shape +p.name: String + +--- query branch: main +query main_kept_its_rows() { match { $p: Person } return { $p.name } } diff --git a/crates/omnigraph-gqt/cases/long_branch_names_first_touch.gqt b/crates/omnigraph-gqt/cases/long_branch_names_first_touch.gqt new file mode 100644 index 00000000..4ae2d4af --- /dev/null +++ b/crates/omnigraph-gqt/cases/long_branch_names_first_touch.gqt @@ -0,0 +1,86 @@ +# issue: none +# notes: First-touch table refs stay bounded when logical names are long or encoded. +--- schema +node Person { + name: String @key + age: I32 +} +--- seed +{"type":"Person","data":{"name":"alice","age":30}} +--- mutate +branch create "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +--- expect ok +--- mutate branch: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +query write() { + update Person set { age: 31 } where name = "alice" +} +--- expect affected: nodes=1 edges=0 +--- query branch: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +query read() { + match { $p: Person } + return { $p.age } +} +--- expect unordered +{"p.age":31} +--- expect shape +p.age: I32 +--- mutate +branch create "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +--- expect ok +--- mutate branch: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +query write() { + update Person set { age: 31 } where name = "alice" +} +--- expect affected: nodes=1 edges=0 +--- query branch: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +query read() { + match { $p: Person } + return { $p.age } +} +--- expect unordered +{"p.age":31} +--- expect shape +p.age: I32 +--- mutate +branch create "team/data/ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +--- expect ok +--- mutate branch: team/data/ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc +query write() { + update Person set { age: 31 } where name = "alice" +} +--- expect affected: nodes=1 edges=0 +--- query branch: team/data/ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc +query read() { + match { $p: Person } + return { $p.age } +} +--- expect unordered +{"p.age":31} +--- expect shape +p.age: I32 +--- mutate +branch create "équipe/échanges" +--- expect ok +--- mutate branch: équipe/échanges +query write() { + update Person set { age: 31 } where name = "alice" +} +--- expect affected: nodes=1 edges=0 +--- query branch: équipe/échanges +query read() { + match { $p: Person } + return { $p.age } +} +--- expect unordered +{"p.age":31} +--- expect shape +p.age: I32 +--- query +query main_is_unchanged() { + match { $p: Person } + return { $p.age } +} +--- expect unordered +{"p.age":30} +--- expect shape +p.age: I32 diff --git a/crates/omnigraph-gqt/cases/owner_write_after_child_forked_from_former_ref.gqt b/crates/omnigraph-gqt/cases/owner_write_after_child_forked_from_former_ref.gqt index 6d693b09..e46e0789 100644 --- a/crates/omnigraph-gqt/cases/owner_write_after_child_forked_from_former_ref.gqt +++ b/crates/omnigraph-gqt/cases/owner_write_after_child_forked_from_former_ref.gqt @@ -1,9 +1,7 @@ # issue: none -# red_on: 2026-09-08, PR 662 @ 2850d86f with the pointer switch: the owner's write armed recovery, its reclaim failed on the former ref's dependent fork, and the next open failed with `cannot discard unpublished fork … parent version is 2, expected exact fork point 4`. -# notes: After the switch, a child that writes the table forks from the former ref, so that ref -# notes: is no longer pinned but is the parent of the child's fork. The owner's next write must -# notes: refuse before arming, name the dependent child, and leave the graph openable; deleting -# notes: the child is the exit, after which the owner's write reclaims the ref and succeeds. +# notes: After a pointer switch, child's write forks from feature's former fork. +# notes: Feature writes successfully while that ancestor is still required by child. +# notes: Both branches retain independent rows after restart and further writes. --- schema node Person { @@ -60,7 +58,7 @@ query owner_writes_after_child_wrote() { update Person set { age: 34 } where name = "alice" } ---- expect error: dependent child branches +--- expect affected: nodes=1 edges=0 --- restart @@ -85,7 +83,7 @@ query feature_reads_after_restart() { } --- expect unordered -{"p.name":"alice","p.age":32} +{"p.name":"alice","p.age":34} --- expect shape p.name: String @@ -98,6 +96,21 @@ query child_writes_again() { --- expect affected: nodes=1 edges=0 +--- query branch: child +query child_reads_its_second_write() { + match { $p: Person } + return { $p.name, $p.age } +} + +--- expect unordered +{"p.name":"alice","p.age":31} +{"p.name":"carol","p.age":1} +{"p.name":"dave","p.age":2} + +--- expect shape +p.name: String +p.age: I32 + --- mutate branch delete child @@ -105,7 +118,7 @@ branch delete child --- mutate branch: feature query owner_writes_after_child_deleted() { - update Person set { age: 34 } where name = "alice" + update Person set { age: 35 } where name = "alice" } --- expect affected: nodes=1 edges=0 @@ -117,7 +130,7 @@ query feature_reads_own_write() { } --- expect unordered -{"p.name":"alice","p.age":34} +{"p.name":"alice","p.age":35} --- expect shape p.name: String diff --git a/crates/omnigraph-gqt/cases/parent_retirement.gqt b/crates/omnigraph-gqt/cases/parent_retirement.gqt new file mode 100644 index 00000000..e4929488 --- /dev/null +++ b/crates/omnigraph-gqt/cases/parent_retirement.gqt @@ -0,0 +1,207 @@ +# issue: none +# notes: Retiring a parent keeps the child's exact rows through writes, restart, +# notes: and reuse of both ancestor names across two retired generations. +# notes: Cleanup and native storage assertions live +# notes: in branching.rs::branch_delete_retires_native_parent_and_cleanup_preserves_live_child. + +--- schema +node Person { + name: String @key + age: I32 +} + +--- seed +{"type":"Person","data":{"name":"alice","age":30}} + +--- mutate +branch create parent + +--- expect ok + +--- mutate branch: parent +query parent_writes() { + update Person set { age: 31 } where name = "alice" +} + +--- expect affected: nodes=1 edges=0 + +--- mutate +branch create child from parent + +--- expect ok + +--- mutate +branch delete parent + +--- expect ok + +--- query +branch list + +--- expect unordered +{"name":"child"} +{"name":"main"} + +--- expect shape +name: String + +--- query branch: parent +query gone_parent() { + match { $p: Person } + return { $p.name } +} + +--- expect error: branch 'parent' not found + +--- mutate branch: child +query child_writes() { + insert Person { name: "bob", age: 40 } +} + +--- expect affected: nodes=1 edges=0 + +--- mutate +branch create parent + +--- expect ok + +--- restart + +--- query branch: child +query child_rows() { + match { $p: Person } + return { $p.name, $p.age } +} + +--- expect unordered +{"p.name":"alice","p.age":31} +{"p.name":"bob","p.age":40} + +--- expect shape +p.name: String +p.age: I32 + +--- query branch: parent +query new_parent_rows() { + match { $p: Person } + return { $p.name, $p.age } +} + +--- expect unordered +{"p.name":"alice","p.age":30} + +--- expect shape +p.name: String +p.age: I32 + +--- mutate +branch create grandchild from child + +--- expect ok + +--- mutate +branch delete child + +--- expect ok + +--- mutate branch: parent +query recreated_parent_writes() { + update Person set { age: 90 } where name = "alice" +} + +--- expect affected: nodes=1 edges=0 + +--- mutate +branch create child from parent + +--- expect ok + +--- restart + +--- query branch: grandchild +query grandchild_keeps_retired_ancestors_rows() { + match { $p: Person } + return { $p.name, $p.age } +} + +--- expect unordered +{"p.name":"alice","p.age":31} +{"p.name":"bob","p.age":40} + +--- expect shape +p.name: String +p.age: I32 + +--- mutate branch: grandchild +query grandchild_writes_after_both_ancestor_names_are_reused() { + update Person set { age: 32 } where name = "alice" +} + +--- expect affected: nodes=1 edges=0 + +--- restart + +--- query branch: grandchild +query grandchild_keeps_its_own_write() { + match { $p: Person } + return { $p.name, $p.age } +} + +--- expect unordered +{"p.name":"alice","p.age":32} +{"p.name":"bob","p.age":40} + +--- expect shape +p.name: String +p.age: I32 + +--- query branch: child +query recreated_child_keeps_its_new_ancestry() { + match { $p: Person } + return { $p.name, $p.age } +} + +--- expect unordered +{"p.name":"alice","p.age":90} + +--- expect shape +p.name: String +p.age: I32 + +--- query branch: parent +query recreated_parent_stays_isolated() { + match { $p: Person } + return { $p.name, $p.age } +} + +--- expect unordered +{"p.name":"alice","p.age":90} + +--- expect shape +p.name: String +p.age: I32 + +--- query +query main_stays_isolated_from_all_generations() { + match { $p: Person } + return { $p.name, $p.age } +} + +--- expect unordered +{"p.name":"alice","p.age":30} + +--- expect shape +p.name: String +p.age: I32 + +--- query +branch list + +--- expect unordered +{"name":"child"} +{"name":"grandchild"} +{"name":"main"} +{"name":"parent"} + +--- expect shape +name: String diff --git a/crates/omnigraph-gqt/src/lib.rs b/crates/omnigraph-gqt/src/lib.rs index 52e399ff..2bb36be6 100644 --- a/crates/omnigraph-gqt/src/lib.rs +++ b/crates/omnigraph-gqt/src/lib.rs @@ -1937,7 +1937,6 @@ async fn run_control_step( expect, } => { let outcome = db.branch_delete_as(branch, None).await; - db.wait_for_fork_reclaims().await; check_write_expect(name, expect, outcome).map_err(fail) } ControlWrite::Merge { diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index b13c3d58..7e2d42f0 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -2101,7 +2101,6 @@ async fn serve_config( Arc::clone(&draining), shutdown_grace, ); - let registry = Arc::clone(&state.routing.registry); let mut shutdown_rx = shutdown_rx; let served = axum::serve(listener, build_app(state)) .with_graceful_shutdown(async move { @@ -2112,12 +2111,6 @@ async fn serve_config( } }) .await; - // The drain finishes in-flight requests, but branch_delete's fork - // reclaims run as detached tasks; join them on both exit paths so - // shutdown does not strand leftovers. - for graph in registry.list() { - graph.engine.wait_for_fork_reclaims().await; - } served?; Ok(()) } diff --git a/crates/omnigraph-server/tests/data_routes.rs b/crates/omnigraph-server/tests/data_routes.rs index 73e50964..b30180da 100644 --- a/crates/omnigraph-server/tests/data_routes.rs +++ b/crates/omnigraph-server/tests/data_routes.rs @@ -2763,7 +2763,7 @@ async fn remote_branch_delete_flow_works() { } #[tokio::test(flavor = "multi_thread")] -async fn branch_merge_delete_branch_deletes_source_after_merge() { +async fn branch_merge_delete_branch_retires_parent_with_live_child() { let (_temp, app) = app_for_loaded_graph().await; let create = BranchCreateRequest { @@ -2800,6 +2800,22 @@ async fn branch_merge_delete_branch_deletes_source_after_merge() { .await; assert_eq!(change_status, StatusCode::OK); + let create_child = BranchCreateRequest { + from: Some("feature".to_string()), + name: "feature-child".to_string(), + }; + let (create_child_status, _) = json_response( + &app, + Request::builder() + .uri(g("/branches")) + .method(Method::POST) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&create_child).unwrap())) + .unwrap(), + ) + .await; + assert_eq!(create_child_status, StatusCode::OK); + let merge = BranchMergeRequest { source: "feature".to_string(), target: Some("main".to_string()), @@ -2830,38 +2846,32 @@ async fn branch_merge_delete_branch_deletes_source_after_merge() { ) .await; assert_eq!(list_status, StatusCode::OK); - assert_eq!(list_body["branches"], json!(["main"])); + assert_eq!(list_body["branches"], json!(["feature-child", "main"])); } #[tokio::test(flavor = "multi_thread")] async fn branch_merge_delete_branch_refusal_is_non_fatal() { let (_temp, app) = app_for_loaded_graph().await; - for (from, name) in [("main", "feature"), ("feature", "feature-child")] { - let create = BranchCreateRequest { - from: Some(from.to_string()), - name: name.to_string(), - }; - let (create_status, _) = json_response( - &app, - Request::builder() - .uri(g("/branches")) - .method(Method::POST) - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&create).unwrap())) - .unwrap(), - ) - .await; - assert_eq!(create_status, StatusCode::OK); - } + let create = BranchCreateRequest { + from: Some("main".to_string()), + name: "feature".to_string(), + }; + let (create_status, _) = json_response( + &app, + Request::builder() + .uri(g("/branches")) + .method(Method::POST) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&create).unwrap())) + .unwrap(), + ) + .await; + assert_eq!(create_status, StatusCode::OK); - // No writes on `feature`, so the merge is `already_up_to_date` — the - // deletion must still be attempted (the "already merged, clean me up" - // case) and its refusal (a dependent descendant branch) must be reported - // without failing the request. let merge = BranchMergeRequest { - source: "feature".to_string(), - target: Some("main".to_string()), + source: "main".to_string(), + target: Some("feature".to_string()), delete_branch: true, }; let (merge_status, merge_body) = json_response( @@ -2881,7 +2891,7 @@ async fn branch_merge_delete_branch_refusal_is_non_fatal() { merge_body["branch_delete_error"] .as_str() .unwrap() - .contains("feature-child") + .contains("cannot delete branch 'main'") ); let (list_status, list_body) = json_response( @@ -2894,10 +2904,7 @@ async fn branch_merge_delete_branch_refusal_is_non_fatal() { ) .await; assert_eq!(list_status, StatusCode::OK); - assert_eq!( - list_body["branches"], - json!(["feature", "feature-child", "main"]) - ); + assert_eq!(list_body["branches"], json!(["feature", "main"])); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/omnigraph/Cargo.toml b/crates/omnigraph/Cargo.toml index 15854cfa..e17b96b7 100644 --- a/crates/omnigraph/Cargo.toml +++ b/crates/omnigraph/Cargo.toml @@ -46,6 +46,7 @@ unicode-normalization = { workspace = true } lance-linalg = { workspace = true } lance-namespace = { workspace = true } lance-table = { workspace = true } +prost = "0.14" arrow-array = { workspace = true } arrow-schema = { workspace = true } arrow-ord = { workspace = true } diff --git a/crates/omnigraph/benches/scenarios.rs b/crates/omnigraph/benches/scenarios.rs index 30d3a907..c2ec611e 100644 --- a/crates/omnigraph/benches/scenarios.rs +++ b/crates/omnigraph/benches/scenarios.rs @@ -331,7 +331,7 @@ fn main() { if args.scenario.is_empty() { eprintln!( "usage: --scenario [--rows N] [--dims D] \ + fenced-adopt-all-new|general-merge-updates|branch-create|branch-create-from|branch-list|branch-delete|branch-pointer-adopt-lazy|branch-pointer-adopt-owned|branch-first-write|branch-cleanup|rrf-gate> [--rows N] [--dims D] \ [--seed S] [--runs K] [--selectivity F] [--k K] [--ann-partitions N] \ [--ann-probes N] [--text-bytes B] [--delta-rows N] \ [--source-mode update|insert] [--branches N] [--tables N] [--memory-cap-mb M] \ diff --git a/crates/omnigraph/benches/scenarios/branch_control.rs b/crates/omnigraph/benches/scenarios/branch_control.rs index d26e582d..83ca4d7a 100644 --- a/crates/omnigraph/benches/scenarios/branch_control.rs +++ b/crates/omnigraph/benches/scenarios/branch_control.rs @@ -8,8 +8,11 @@ use std::fmt::Write as _; use std::path::Path; use std::time::Instant; +use arrow_array::{Array as _, FixedSizeListArray, Float32Array, StringArray}; +use futures::TryStreamExt as _; use lance::dataset::builder::DatasetBuilder; -use omnigraph::db::{Omnigraph, ReadTarget}; +use omnigraph::db::{CleanupPolicyOptions, MergeOutcome, Omnigraph, ReadTarget}; +use omnigraph::instrumentation::{MergeWriteProbes, with_merge_write_probes}; use omnigraph::loader::LoadMode; use serde::{Deserialize, Serialize}; @@ -22,7 +25,14 @@ const EXPECTED_FILE: &str = "branch-control-expected.json"; pub(super) fn is_scenario(name: &str) -> bool { matches!( name, - "branch-create" | "branch-create-from" | "branch-list" | "branch-delete" + "branch-create" + | "branch-create-from" + | "branch-list" + | "branch-delete" + | "branch-pointer-adopt-lazy" + | "branch-pointer-adopt-owned" + | "branch-first-write" + | "branch-cleanup" ) } @@ -35,6 +45,14 @@ pub(super) fn validate_args(args: &Args) -> Result<(), String> { "branch scenarios measure one real operation and have no --baseline arm".into(), ); } + if is_content_scenario(args) + && (args.rows > 16_384 || args.dims > 16 || args.branches > 8 || args.tables > 8) + { + return Err( + "exact content scenarios require rows <= 16384, dims <= 16, branches/tables <= 8" + .into(), + ); + } rfc023_limits::derive_chunk_plan(args.dims, "base", args.rows)?; if args.child { if !matches!( @@ -52,7 +70,7 @@ pub(super) fn validate_args(args: &Args) -> Result<(), String> { Ok(()) } -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] struct TableView { path: String, native_ref: Option, @@ -67,9 +85,14 @@ struct BranchView { tables: BTreeMap, } -#[derive(Serialize, Deserialize)] +#[derive(Default, Serialize, Deserialize)] struct Fixture { branches: BTreeMap, + payloads: BTreeMap, + physical: BTreeMap, + collectible: Vec, + required_parent: Option, + required_parent_rows: TableRows, } fn root(args: &Args) -> &Path { @@ -170,10 +193,26 @@ pub(super) async fn setup(args: &Args) -> serde_json::Value { assert_eq!(loaded.nodes_loaded.values().sum::(), 1); } let age = rfc023_scenarios::age_fixture(&db, args).await; - for branch in 0..args.branches { - db.branch_create(&format!("control-sibling-{branch}")) + let mut fixture = Fixture::default(); + if args.scenario == "branch-cleanup" { + fixture.collectible = + serde_json::from_value(age["setup_retired_table_refs"].clone()).unwrap(); + } + if args.scenario == "branch-pointer-adopt-owned" { + db.branch_create(TARGET).await.unwrap(); + db.load(TARGET, &one_row(args, "owner-only"), LoadMode::Append) .await - .expect("create sibling"); + .unwrap(); + } + for branch in 0..args.branches { + let name = format!("control-sibling-{branch}"); + if branch == 0 && args.scenario == "branch-pointer-adopt-owned" { + db.branch_create_from(ReadTarget::branch(TARGET), &name) + .await + .unwrap(); + } else { + db.branch_create(&name).await.expect("create sibling"); + } } // A named source that differs from main proves create-from used that // source, rather than accidentally measuring another main fork. @@ -187,12 +226,19 @@ pub(super) async fn setup(args: &Args) -> serde_json::Value { LoadMode::Append, ) .await; + if args.scenario == "branch-pointer-adopt-lazy" { + db.branch_create(TARGET).await.unwrap(); + } else if args.scenario == "branch-first-write" { + db.branch_create_from(ReadTarget::branch(SOURCE), TARGET) + .await + .unwrap(); + } else if args.scenario == "branch-cleanup" { + prepare_cleanup(args, &db, &mut fixture).await; + } if args.scenario == "branch-delete" { db.branch_create(TARGET) .await .expect("create deletion victim"); - // Own every table on the victim, so delete exercises actual native - // ref cleanup as well as its surviving-branch dependency checks. rfc023_scenarios::load_graph_rows( &db, TARGET, @@ -213,7 +259,10 @@ pub(super) async fn setup(args: &Args) -> serde_json::Value { let layout = fixture_controls::prepare_layout(uri, args).await; let mut names = db.branch_list().await.expect("list prepared branches"); names.sort(); - let expected_count = args.branches + 1 + usize::from(args.scenario == "branch-delete"); + let expected_count = args.branches + + 1 + + usize::from(has_prepared_target(args)) + + 2 * usize::from(args.scenario == "branch-cleanup"); assert_eq!(names.len(), expected_count); let mut branches = BTreeMap::new(); for branch in names { @@ -225,7 +274,10 @@ pub(super) async fn setup(args: &Args) -> serde_json::Value { "branch registry contains duplicates" ); let source = &branches[SOURCE]; - assert_eq!(source.tables["node:Chunk"].rows, args.rows as u64 + 1); + assert_eq!( + source.tables["node:Chunk"].rows, + args.rows as u64 + 1 + u64::from(args.scenario == "branch-pointer-adopt-owned") + ); assert_ne!( source.tables, branches["main"].tables, "named source must differ from main" @@ -238,7 +290,24 @@ pub(super) async fn setup(args: &Args) -> serde_json::Value { "fixture parents must have real content heads, not synthetic snapshot IDs" ); } - let fixture = Fixture { branches }; + fixture.branches = branches; + if is_content_scenario(args) { + for branch in fixture.branches.keys() { + fixture + .payloads + .insert(branch.clone(), branch_rows(&db, branch).await); + } + fixture.physical = physical_tables(&db).await; + } + if is_pointer_adoption(args) { + let target = &fixture.branches[TARGET].tables["node:Chunk"]; + let source = &fixture.branches[SOURCE].tables["node:Chunk"]; + assert_eq!( + target.native_ref.is_some(), + args.scenario == "branch-pointer-adopt-owned" + ); + assert_ne!(target.native_ref, source.native_ref); + } std::fs::write( root.join(EXPECTED_FILE), serde_json::to_vec(&fixture).unwrap(), @@ -256,6 +325,9 @@ pub(super) async fn setup(args: &Args) -> serde_json::Value { "victim_owned_table_count": if args.scenario == "branch-delete" { args.tables } else { 0 }, "source_branch": if args.scenario == "branch-create-from" { SOURCE } else { "main" }, "setup_verified": true, + "setup_exact_rows_verified": is_content_scenario(args), + "setup_cleanup_collectible_refs": fixture.collectible.len(), + "setup_required_native_parent": fixture.required_parent.is_some(), }); metrics .as_object_mut() @@ -271,7 +343,7 @@ pub(super) async fn setup(args: &Args) -> serde_json::Value { pub(super) async fn operation(args: &Args) -> serde_json::Value { super::helpers::cost::cost_harness(async { let root = root(args); - let ((db, operation_open_us), open_io) = super::helpers::cost::measure(async { + let ((mut db, operation_open_us), open_io) = super::helpers::cost::measure(async { let open_start = Instant::now(); let db = Omnigraph::open(root.to_str().unwrap()) .await @@ -280,11 +352,14 @@ pub(super) async fn operation(args: &Args) -> serde_json::Value { (db, operation_open_us) }).await; let prewarm = fixture_controls::prewarm(&db, args).await; + let probes = MergeWriteProbes::default(); + let payload = if args.scenario == "branch-first-write" { one_row(args, "target-only") } else { String::new() }; let operation_pre_peak_rss_bytes = super::current_process_peak_rss_bytes(); - let ((listed, operation_wall_us, operation_post_peak_rss_bytes, + let ((listed, merge_outcome, operation_wall_us, operation_post_peak_rss_bytes, post_ack_reclaim_wait_us, operation_complete_wall_us, operation_completion_peak_rss_bytes), io) = super::helpers::cost::measure(async { let started = Instant::now(); + let mut merge_outcome = None; let listed = match args.scenario.as_str() { "branch-create" => { db.branch_create(TARGET) @@ -305,25 +380,35 @@ pub(super) async fn operation(args: &Args) -> serde_json::Value { .expect("measured branch delete"); None } + "branch-pointer-adopt-lazy" | "branch-pointer-adopt-owned" => { + merge_outcome = Some(with_merge_write_probes(probes.clone(), db.branch_merge(SOURCE, TARGET)).await.expect("measured pointer adoption")); + None + } + "branch-first-write" => { + db.load(TARGET, &payload, LoadMode::Append).await.expect("measured first-touch write"); + None + } + "branch-cleanup" => { + db.cleanup(CleanupPolicyOptions { keep_versions: Some(1), older_than: None }).await.expect("measured explicit cleanup"); + None + } _ => unreachable!("validated branch scenario"), }; let operation_wall_us = started.elapsed().as_micros() as u64; let operation_post_peak_rss_bytes = super::current_process_peak_rss_bytes(); - // The public delete acknowledges authority removal before its owned - // table forks finish reclaiming. Keep that acknowledgement timer intact, - // then join this handle's work before the operation child exits. - let post_ack_reclaim_wait_us = if args.scenario == "branch-delete" { - let reclaim_started = Instant::now(); - db.wait_for_fork_reclaims().await; - Some(reclaim_started.elapsed().as_micros() as u64) - } else { - None - }; + let post_ack_reclaim_wait_us: Option = None; let operation_complete_wall_us = started.elapsed().as_micros() as u64; let operation_completion_peak_rss_bytes = super::current_process_peak_rss_bytes(); - (listed, operation_wall_us, operation_post_peak_rss_bytes, + (listed, merge_outcome, operation_wall_us, operation_post_peak_rss_bytes, post_ack_reclaim_wait_us, operation_complete_wall_us, operation_completion_peak_rss_bytes) }).await; + if is_pointer_adoption(args) { + assert_eq!(merge_outcome, Some(MergeOutcome::FastForward)); + assert_eq!(io.data_writes, 0, "pointer adoption must not write table objects"); + assert_eq!(probes.stage_fenced_insert_calls(), 0); + assert_eq!(probes.stage_merge_insert_calls(), 0); + assert_eq!(probes.stage_append_calls(), 0); + } let first_read = if args.age_options_supplied && matches!(args.scenario.as_str(), "branch-create" | "branch-create-from") { fixture_controls::first_read(&db, TARGET, args.tables).await } else { @@ -341,6 +426,10 @@ pub(super) async fn operation(args: &Args) -> serde_json::Value { let mut metrics = serde_json::json!({ "routing": "production-omnigraph-branch-control", "operation": args.scenario, + "merge_outcome": merge_outcome.map(|_| "fast_forward"), + "probe_stage_fenced_insert_calls": probes.stage_fenced_insert_calls(), + "probe_stage_merge_insert_calls": probes.stage_merge_insert_calls(), + "probe_stage_append_calls": probes.stage_append_calls(), "completed_operations": 1, "production_path": true, "operation_open_us": operation_open_us, @@ -353,8 +442,8 @@ pub(super) async fn operation(args: &Args) -> serde_json::Value { "operation_post_peak_rss_bytes": operation_post_peak_rss_bytes, "operation_completion_peak_rss_bytes": operation_completion_peak_rss_bytes, "operation_hwm_increase_bytes": operation_post_peak_rss_bytes.checked_sub(operation_pre_peak_rss_bytes).filter(|v| *v > 0), - "measurement_boundary": "exactly one public branch operation after separately recorded fresh open and optional prewarm; operation_wall is acknowledgement, operation_complete_wall also waits for delete fork reclaim; optional fork first_read is separate and later; setup and final verification run in separate children", - "rss_boundary": "operation child whole-process wait4 HWM includes runtime, graph open, optional prewarm/first payload read, delete fork reclaim and output recording, excludes setup/verify; pre/post/completion self HWM is not isolated allocation", + "measurement_boundary": "exactly one public branch operation after separately recorded fresh open and optional prewarm; operation_wall is acknowledgement, operation_complete_wall records the same foreground completion; delete leaves table forks for explicit cleanup; optional fork first_read is separate and later; setup and final verification run in separate children", + "rss_boundary": "operation child whole-process wait4 HWM includes runtime, graph open, optional prewarm/first payload read and output recording, excludes setup/verify; pre/post/completion self HWM is not isolated allocation", "listed_branch_count": listed.as_ref().map(Vec::len), }); metrics.as_object_mut().unwrap().extend(rfc023_scenarios::operation_io_metrics(&io).as_object().unwrap().clone()); @@ -373,7 +462,10 @@ pub(super) async fn verify(args: &Args) -> serde_json::Value { let db = Omnigraph::open(root.to_str().unwrap()) .await .expect("open branch verification"); - let mut verified_reclaimed_table_refs = 0; + if is_content_scenario(args) { + return verify_content(args, &db, &fixture).await; + } + let mut verified_deferred_table_refs = 0; if args.scenario == "branch-delete" { let victim = fixture .branches @@ -392,18 +484,18 @@ pub(super) async fn verify(args: &Args) -> serde_json::Value { let dataset = DatasetBuilder::from_uri(format!("{}/{}", root.display(), entry.path)) .load() .await - .expect("open surviving physical table for reclaim verification"); + .expect("open surviving physical table for deferred cleanup verification"); let refs = dataset .list_branches() .await .expect("list physical table refs"); assert!( - !refs.contains_key(native_ref), - "acknowledged delete did not reclaim an owned native table ref" + refs.contains_key(native_ref), + "delete must leave the former table fork for explicit cleanup" ); - verified_reclaimed_table_refs += 1; + verified_deferred_table_refs += 1; } - assert_eq!(verified_reclaimed_table_refs, args.tables); + assert_eq!(verified_deferred_table_refs, args.tables); } let mut names = db.branch_list().await.expect("list final branches"); names.sort(); @@ -455,7 +547,439 @@ pub(super) async fn verify(args: &Args) -> serde_json::Value { "verification_passed": true, "verified_branch_count_including_main": names.len(), "verified_table_views": names.len() * args.tables, - "verified_reclaimed_table_refs": verified_reclaimed_table_refs, + "verified_deferred_table_refs": verified_deferred_table_refs, "verify_wall_us": started.elapsed().as_micros() as u64, }) } + +fn is_pointer_adoption(args: &Args) -> bool { + matches!( + args.scenario.as_str(), + "branch-pointer-adopt-lazy" | "branch-pointer-adopt-owned" + ) +} + +fn is_content_scenario(args: &Args) -> bool { + is_pointer_adoption(args) + || matches!( + args.scenario.as_str(), + "branch-first-write" | "branch-cleanup" + ) +} + +fn has_prepared_target(args: &Args) -> bool { + is_pointer_adoption(args) + || matches!( + args.scenario.as_str(), + "branch-first-write" | "branch-delete" + ) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct PayloadRow { + id: String, + embedding_bits: Option>, +} + +type TableRows = BTreeMap; +type BranchRows = BTreeMap; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct RefHead { + version: u64, + identifier: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct PhysicalTable { + main_version: u64, + refs: BTreeMap, +} + +async fn table_rows( + mut stream: lance::dataset::scanner::DatasetRecordBatchStream, + key: &str, +) -> TableRows { + let mut rows = BTreeMap::new(); + while let Some(batch) = stream.try_next().await.expect("read exact fixture rows") { + assert_eq!(batch.num_columns(), if key == "node:Chunk" { 3 } else { 2 }); + let ids = batch + .column_by_name("id") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + let slugs = batch + .column_by_name("slug") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + for row in 0..batch.num_rows() { + assert!(!ids.is_null(row) && !slugs.is_null(row)); + let embedding_bits = batch.column_by_name("embedding").map(|column| { + let lists = column + .as_any() + .downcast_ref::() + .unwrap(); + assert!(!lists.is_null(row)); + let value = lists.value(row); + let values = value.as_any().downcast_ref::().unwrap(); + assert_eq!(values.null_count(), 0); + values + .values() + .iter() + .map(|value| value.to_bits()) + .collect() + }); + let previous = rows.insert( + slugs.value(row).to_string(), + PayloadRow { + id: ids.value(row).to_string(), + embedding_bits, + }, + ); + assert!(previous.is_none(), "duplicate fixture key"); + } + } + rows +} + +async fn branch_rows(db: &Omnigraph, branch: &str) -> BranchRows { + let snapshot = db.snapshot_of(ReadTarget::branch(branch)).await.unwrap(); + let mut rows = BTreeMap::new(); + for entry in snapshot.datasets() { + let table = snapshot.open_dataset(&entry.type_key).await.unwrap(); + let payload = table_rows( + table + .scan() + .batch_size(1024) + .try_into_stream() + .await + .unwrap(), + &entry.type_key, + ) + .await; + assert_eq!(payload.len() as u64, entry.entity_count); + rows.insert(entry.type_key.clone(), payload); + } + rows +} + +async fn physical_tables(db: &Omnigraph) -> BTreeMap { + let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap(); + let mut tables = BTreeMap::new(); + for entry in snapshot.datasets() { + let dataset = DatasetBuilder::from_uri(format!("{}/{}", db.uri(), entry.dataset_path)) + .load() + .await + .unwrap(); + let mut refs = BTreeMap::new(); + for (native, contents) in dataset.list_branches().await.unwrap() { + let head = dataset.checkout_branch(&native).await.unwrap(); + refs.insert( + native, + RefHead { + version: head.version().version, + identifier: format!("{:?}", contents.identifier), + }, + ); + } + tables.insert( + entry.dataset_path.clone(), + PhysicalTable { + main_version: dataset.version().version, + refs, + }, + ); + } + tables +} + +async fn chunk_view(db: &Omnigraph, branch: &str) -> TableView { + let snapshot = db.snapshot_of(ReadTarget::branch(branch)).await.unwrap(); + let entry = snapshot.dataset("node:Chunk").unwrap(); + TableView { + path: entry.dataset_path.clone(), + native_ref: entry.native_dataset_branch.clone(), + version: entry.published_dataset_version, + rows: entry.entity_count, + } +} + +fn one_row(args: &Args, prefix: &str) -> String { + rfc023_scenarios::graph_jsonl_chunk( + prefix, + 0, + 1, + &rfc023_scenarios::vector_json_patterns(args.dims, args.seed), + ) +} + +fn expected_row(args: &Args, prefix: &str) -> (String, PayloadRow) { + let key = format!("{prefix}-0000000000"); + let patterns = rfc023_scenarios::vector_json_patterns(args.dims, args.seed); + let values: Vec = serde_json::from_str(&format!("[{}]", patterns[0])).unwrap(); + let row = PayloadRow { + id: key.clone(), + embedding_bits: Some(values.into_iter().map(f32::to_bits).collect()), + }; + (key, row) +} + +async fn prepare_cleanup(args: &Args, db: &Omnigraph, fixture: &mut Fixture) { + if fixture.collectible.is_empty() { + db.branch_create("control-garbage").await.unwrap(); + db.load( + "control-garbage", + &one_row(args, "garbage-only"), + LoadMode::Append, + ) + .await + .unwrap(); + fixture + .collectible + .push(chunk_view(db, "control-garbage").await); + db.branch_delete("control-garbage").await.unwrap(); + } + db.branch_create("control-retained-parent").await.unwrap(); + db.load( + "control-retained-parent", + &one_row(args, "parent-only"), + LoadMode::Append, + ) + .await + .unwrap(); + fixture.required_parent = Some(chunk_view(db, "control-retained-parent").await); + fixture.required_parent_rows = branch_rows(db, "control-retained-parent") + .await + .remove("node:Chunk") + .unwrap(); + db.branch_create_from( + ReadTarget::branch("control-retained-parent"), + "control-retained-child", + ) + .await + .unwrap(); + db.load( + "control-retained-child", + &one_row(args, "child-only"), + LoadMode::Append, + ) + .await + .unwrap(); + assert_eq!( + db.branch_merge("control-retained-parent", "main") + .await + .unwrap(), + MergeOutcome::FastForward + ); + db.load("main", &one_row(args, "main-only"), LoadMode::Append) + .await + .unwrap(); + assert_eq!( + db.branch_merge("main", "control-retained-parent") + .await + .unwrap(), + MergeOutcome::FastForward + ); + let parent = fixture.required_parent.as_ref().unwrap(); + let owner = chunk_view(db, "control-retained-parent").await; + let child = chunk_view(db, "control-retained-child").await; + assert_ne!( + owner.native_ref, parent.native_ref, + "parent table fork must have been abandoned" + ); + assert_ne!( + child.native_ref, parent.native_ref, + "child must own a descendant table fork" + ); + verify_required_parent(db, fixture).await; +} + +async fn verify_required_parent(db: &Omnigraph, fixture: &Fixture) { + let parent = fixture.required_parent.as_ref().unwrap(); + let table = DatasetBuilder::from_uri(format!("{}/{}", db.uri(), parent.path)) + .load() + .await + .unwrap(); + let parent_table = table + .checkout_branch(parent.native_ref.as_deref().unwrap()) + .await + .unwrap(); + let child = chunk_view(db, "control-retained-child").await; + let child_table = table + .checkout_branch(child.native_ref.as_deref().unwrap()) + .await + .unwrap(); + assert_eq!( + child_table + .branch_identifier() + .await + .unwrap() + .find_referenced_version(&parent_table.branch_identifier().await.unwrap()), + Some(parent.version) + ); + let pinned = parent_table.checkout_version(parent.version).await.unwrap(); + assert_eq!( + table_rows( + pinned + .scan() + .batch_size(1024) + .try_into_stream() + .await + .unwrap(), + "node:Chunk" + ) + .await, + fixture.required_parent_rows + ); +} + +async fn verify_content(args: &Args, db: &Omnigraph, fixture: &Fixture) -> serde_json::Value { + let mut names = db.branch_list().await.unwrap(); + names.sort(); + assert_eq!(names, fixture.branches.keys().cloned().collect::>()); + let modifies_target = is_pointer_adoption(args) || args.scenario == "branch-first-write"; + for (branch, expected) in &fixture.branches { + if modifies_target && branch == TARGET { + continue; + } + assert_eq!(&branch_view(db, branch, args.tables).await, expected); + assert_eq!(branch_rows(db, branch).await, fixture.payloads[branch]); + } + let mut verified_pointer_tables = 0; + let mut verified_fresh_forks = 0; + if modifies_target { + let actual = branch_view(db, TARGET, args.tables).await; + let before = &fixture.branches[TARGET]; + assert_ne!(actual.effective_head, before.effective_head); + if is_pointer_adoption(args) { + assert_eq!(actual.tables, fixture.branches[SOURCE].tables); + assert_eq!(branch_rows(db, TARGET).await, fixture.payloads[SOURCE]); + verified_pointer_tables = actual.tables.len(); + } else { + let mut expected = fixture.payloads[TARGET].clone(); + let (key, row) = expected_row(args, "target-only"); + assert!( + expected + .get_mut("node:Chunk") + .unwrap() + .insert(key, row) + .is_none() + ); + assert_eq!(branch_rows(db, TARGET).await, expected); + for (key, old) in &before.tables { + let new = &actual.tables[key]; + if key == "node:Chunk" { + assert_eq!(new.path, old.path); + assert_eq!(new.rows, old.rows + 1); + assert!(new.native_ref.is_some()); + assert_ne!(new.native_ref, old.native_ref); + verified_fresh_forks += 1; + } else { + assert_eq!(new, old); + } + } + } + } + let physical = physical_tables(db).await; + if is_pointer_adoption(args) { + assert_eq!( + physical, fixture.physical, + "pointer adoption must not alter table refs or HEADs" + ); + } else if args.scenario == "branch-first-write" { + let written = chunk_view(db, TARGET).await; + for (path, before) in &fixture.physical { + let after = &physical[path]; + assert_eq!(before.main_version, after.main_version); + for (native, head) in &before.refs { + assert_eq!(after.refs.get(native), Some(head)); + } + assert_eq!( + after.refs.len(), + before.refs.len() + usize::from(path == &written.path) + ); + if path == &written.path { + let native = written.native_ref.as_ref().unwrap(); + assert!(!before.refs.contains_key(native)); + assert_eq!(after.refs[native].version, written.version); + } + } + } else { + let mut expected = fixture.physical.clone(); + for retired in &fixture.collectible { + assert!( + expected + .get_mut(&retired.path) + .unwrap() + .refs + .remove(retired.native_ref.as_ref().unwrap()) + .is_some() + ); + } + assert_eq!( + physical, expected, + "cleanup must collect garbage and retain required native ancestry" + ); + verify_required_parent(db, fixture).await; + } + if is_pointer_adoption(args) { + let source = db.snapshot_of(ReadTarget::branch(SOURCE)).await.unwrap(); + let old_target = chunk_view(db, TARGET).await; + db.load(TARGET, &one_row(args, "verify-only"), LoadMode::Append) + .await + .unwrap(); + let written = chunk_view(db, TARGET).await; + assert_ne!(written.native_ref, old_target.native_ref); + assert_ne!( + written.native_ref, + fixture.branches[TARGET].tables["node:Chunk"].native_ref + ); + let after_write = physical_tables(db).await; + for (path, before) in &physical { + let after = &after_write[path]; + assert_eq!(after.main_version, before.main_version); + for (native, head) in &before.refs { + assert_eq!(after.refs.get(native), Some(head)); + } + assert_eq!( + after.refs.len(), + before.refs.len() + usize::from(path == &written.path) + ); + } + let after_source = db.snapshot_of(ReadTarget::branch(SOURCE)).await.unwrap(); + for entry in source.datasets() { + assert!( + after_source + .dataset(&entry.type_key) + .unwrap() + .same_registration(entry) + ); + } + let mut expected = fixture.payloads[SOURCE].clone(); + let (key, row) = expected_row(args, "verify-only"); + assert!( + expected + .get_mut("node:Chunk") + .unwrap() + .insert(key, row) + .is_none() + ); + assert_eq!(branch_rows(db, TARGET).await, expected); + assert_eq!(branch_rows(db, SOURCE).await, fixture.payloads[SOURCE]); + } + serde_json::json!({ + "verification_passed": true, + "verified_exact_rows": true, + "verified_branch_count_including_main": names.len(), + "verified_table_views": names.len() * args.tables, + "verified_pointer_tables": verified_pointer_tables, + "verified_fresh_forks": verified_fresh_forks, + "verified_collected_table_refs": if args.scenario == "branch-cleanup" { fixture.collectible.len() } else { 0 }, + "verified_required_native_parent": args.scenario == "branch-cleanup", + "verified_post_adopt_isolation": is_pointer_adoption(args), + "verification_boundary": "all row, pin and physical-head checks and the post-adoption isolation write occur in the separate untimed verification child", + }) +} diff --git a/crates/omnigraph/benches/scenarios/rfc023.rs b/crates/omnigraph/benches/scenarios/rfc023.rs index 52adb3e6..5f1d60b0 100644 --- a/crates/omnigraph/benches/scenarios/rfc023.rs +++ b/crates/omnigraph/benches/scenarios/rfc023.rs @@ -477,7 +477,12 @@ pub(super) fn vector_json_patterns(dims: usize, seed: u64) -> Vec { .collect() } -fn graph_jsonl_chunk(prefix: &str, start: usize, end: usize, vector_patterns: &[String]) -> String { +pub(super) fn graph_jsonl_chunk( + prefix: &str, + start: usize, + end: usize, + vector_patterns: &[String], +) -> String { let approximate_row_bytes = vector_patterns .first() .map_or(128, |vector| vector.len().saturating_add(128)); @@ -545,6 +550,7 @@ pub(super) async fn age_fixture(db: &Omnigraph, args: &Args) -> serde_json::Valu .await .expect("capture aged main head"); let mut retired = 0; + let mut retired_table_refs = Vec::new(); for index in 0..args.retired_branches { let branch = format!("age-retired-{index}"); db.branch_create(&branch) @@ -575,12 +581,17 @@ pub(super) async fn age_fixture(db: &Omnigraph, args: &Args) -> serde_json::Valu args.rows + 1, "retired append must add one row" ); + retired_table_refs.push(serde_json::json!({ + "path": entry.dataset_path, + "native_ref": native, + "version": entry.published_dataset_version, + "rows": entry.entity_count, + })); drop(table); drop(snapshot); db.branch_delete(&branch) .await .expect("retire fixture branch"); - db.wait_for_fork_reclaims().await; assert!( !db.branch_list().await.unwrap().contains(&branch), "retired graph branch remains live" @@ -590,12 +601,12 @@ pub(super) async fn age_fixture(db: &Omnigraph, args: &Args) -> serde_json::Valu .await .expect("open surviving table after retirement"); assert!( - !physical + physical .list_branches() .await .unwrap() .contains_key(&native), - "retired native fork was not reclaimed" + "retired native fork must remain until cleanup" ); retired += 1; } @@ -625,7 +636,8 @@ pub(super) async fn age_fixture(db: &Omnigraph, args: &Args) -> serde_json::Valu "setup_main_history_after_age": after, "setup_retired_branches_requested": args.retired_branches, "setup_retired_branches_applied": retired, - "setup_retired_native_refs_reclaimed": retired, + "setup_retired_native_refs_retained": retired, + "setup_retired_table_refs": retired_table_refs, "setup_age_content_verified": true, "setup_age_wall_us": started.elapsed().as_micros() as u64, "setup_age_semantics": "paired current-format main updates restore exact first-row content; retired branch commits are not reachable main history", @@ -637,12 +649,13 @@ pub(super) fn operation_io_metrics(io: &super::helpers::cost::IoCounts) -> serde "operation_io_manifest_reads": io.manifest_reads, "operation_io_manifest_read_bytes": io.manifest_read_bytes, "operation_io_data_reads": io.data_reads, + "operation_io_data_writes": io.data_writes, "operation_io_data_opener_reads": io.data_opener_reads, "operation_io_data_scan_reads": io.data_scan_reads, "operation_io_internal_open_count": io.internal_open_count, "operation_io_data_open_count": io.data_open_count, "operation_io_manifest_scan_count": io.manifest_scan_count, - "operation_io_boundary": "shared cost_harness installed before open; measure resets before operation and collects after timing; task-local foreground probes exclude spawned reclaim and unwrapped warm data handles; not all filesystem I/O", + "operation_io_boundary": "shared cost_harness installed before open; measure resets before operation and collects after timing; task-local foreground probes exclude unwrapped warm data handles; data_writes counts instrumented node/edge object-store writes, not total storage requests or deletes; not all filesystem I/O", }) } diff --git a/crates/omnigraph/src/branch_control.rs b/crates/omnigraph/src/branch_control.rs index b701471a..c5b19ab2 100644 --- a/crates/omnigraph/src/branch_control.rs +++ b/crates/omnigraph/src/branch_control.rs @@ -1,12 +1,8 @@ -//! Crash classification for Lance's native branch controls. +//! Crash classification for Lance's public 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. +//! Graph retirement marks logical authority in branch metadata while preserving +//! native refs and trees for explicit cleanup. Controls remain serialized by +//! OmniGraph's single-writer-process schema, branch, and table gates. use std::collections::HashMap; @@ -18,8 +14,7 @@ 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. - /// Boxed: `Dataset` is ~336 bytes and `RefAlreadyExists` carries none. - Created(Box), + Created, /// 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 @@ -53,6 +48,156 @@ pub(crate) async fn list_branch_contents( unreachable!("the bounded branch-enumeration loop always returns") } +/// Enumerate physical native lifetimes, including retired graph manifests. +pub(crate) async fn list_all_branch_contents( + dataset: &Dataset, +) -> Result> { + list_branch_contents(dataset).await +} + +const RETIRED_MANIFEST_BRANCH_KEY: &str = "omnigraph.retired_manifest_branch"; + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct RetiredManifestBranch { + version: u32, + native_branch: String, + identifier: BranchIdentifier, +} + +fn manifest_branch_is_live(branch: &str, contents: &BranchContents) -> Result { + let Some(value) = contents.metadata.get(RETIRED_MANIFEST_BRANCH_KEY) else { + return Ok(true); + }; + let record: RetiredManifestBranch = serde_json::from_str(value).map_err(|error| { + OmniError::manifest_conflict(format!( + "native manifest branch '{branch}' has invalid retirement metadata: {error}" + )) + })?; + if record.version != 1 + || branch == "main" + || record.native_branch != branch + || record.identifier != contents.identifier + || record.identifier == BranchIdentifier::main() + || record.identifier == BranchIdentifier::missing_identifier_sentinel() + { + return Err(OmniError::manifest_conflict(format!( + "native manifest branch '{branch}' has unsupported or mismatched retirement metadata" + ))); + } + Ok(false) +} + +/// Validate all physical refs before selecting live graph incarnations. +pub(crate) async fn list_live_manifest_branch_contents( + dataset: &Dataset, +) -> Result> { + let mut live = HashMap::new(); + for (branch, contents) in list_branch_contents(dataset).await? { + if manifest_branch_is_live(&branch, &contents)? { + live.insert(branch, contents); + } + } + Ok(live) +} + +/// Read exact native authority and refuse retired or malformed graph refs. +pub(crate) async fn get_live_manifest_branch_contents( + dataset: &Dataset, + branch: &str, +) -> Result { + let not_found = || OmniError::BranchNotFound { + branch: crate::branch_names::logical_branch_name(branch).to_string(), + }; + let contents = match dataset.branches().get(branch).await { + Ok(contents) => contents, + Err(lance::Error::RefNotFound { .. }) => { + return Err(not_found()); + } + Err(error) => return Err(OmniError::storage(error)), + }; + if !manifest_branch_is_live(branch, &contents)? { + return Err(not_found()); + } + Ok(contents) +} + +/// Retire logical authority through public metadata; cleanup owns physical refs. +/// Callers hold schema, branch, and table gates in one writer process. +/// The metadata replacement is one-object publication, without a native CAS. +pub(crate) async fn retire_branch_recoverably( + dataset: &Dataset, + branch: &str, + expected_identifier: &BranchIdentifier, +) -> Result<()> { + if branch == "main" { + return Err(OmniError::manifest_conflict("cannot retire branch 'main'")); + } + let contents = dataset + .branches() + .get(branch) + .await + .map_err(OmniError::storage)?; + if contents.identifier != *expected_identifier { + return Err(OmniError::manifest_conflict(format!( + "branch '{branch}' changed before retirement" + ))); + } + if !manifest_branch_is_live(branch, &contents)? { + return Ok(()); + } + if expected_identifier == &BranchIdentifier::main() + || expected_identifier == &BranchIdentifier::missing_identifier_sentinel() + { + return Err(OmniError::manifest_conflict(format!( + "branch '{branch}' has no exact retirement identity" + ))); + } + let tags = dataset.tags().list().await.map_err(OmniError::storage)?; + if let Some((tag, _)) = tags + .iter() + .find(|(_, tag)| tag.branch.as_deref() == Some(branch)) + { + return Err(OmniError::storage(lance::Error::RefConflict { + message: format!( + "cannot retire graph branch '{branch}' while native manifest tag '{tag}' pins it; remove the tag first" + ), + })); + } + let record = RetiredManifestBranch { + version: 1, + native_branch: branch.to_string(), + identifier: expected_identifier.clone(), + }; + let value = serde_json::to_string(&record).map_err(|error| { + OmniError::manifest_internal(format!("failed to encode branch retirement: {error}")) + })?; + let mut metadata = contents.metadata; + metadata.insert(RETIRED_MANIFEST_BRANCH_KEY.to_string(), value); + let result = match dataset.branches().replace_metadata(branch, metadata).await { + Ok(()) => { + crate::failpoints::maybe_fail(crate::failpoints::names::BRANCH_DELETE_POST_NATIVE) + } + Err(error) => Err(OmniError::storage(error)), + }; + let Err(error) = result else { return Ok(()) }; + let observed = dataset + .branches() + .get(branch) + .await + .map_err(OmniError::storage)?; + if observed.identifier != *expected_identifier { + return Err(OmniError::manifest_conflict(format!( + "branch '{branch}' changed during retirement" + ))); + } + if manifest_branch_is_live(branch, &observed)? { + Err(error) + } else { + Ok(()) + } +} + /// Pinned Lance treats an already-absent target tree as success. OmniGraph /// still normalizes `RefNotFound` / `NotFound` around the whole call because /// Lance's branch-contents existence check and delete are separate operations, @@ -144,6 +289,11 @@ pub(crate) async fn reclaim_ref_absent_tree(dataset: &mut Dataset, branch: &str) if branches.contains_key(branch) { return Ok(false); } + match dataset.branches().get(branch).await { + Ok(_) => return Ok(false), + Err(lance::Error::RefNotFound { .. }) => {} + Err(error) => return Err(OmniError::storage(error)), + } 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 \ @@ -181,6 +331,28 @@ fn matches_create_expectation( .is_some_and(|(version, uuid)| *version == parent_version && !uuid.is_empty()) } +/// Create a fresh table fork once; the caller's persisted intent owns any effects. +/// Errors retain recovery ownership until classification. Cleanup reclaims garbage. +pub(crate) async fn create_unique_table_fork( + source: &mut Dataset, + branch: &str, + source_version: u64, +) -> Result { + check_valid_branch(branch).map_err(OmniError::storage)?; + if source.version().version != source_version { + return Err(OmniError::manifest_conflict(format!( + "table fork source moved: expected version {}, current {}", + source_version, + source.version().version, + ))); + } + let created = crate::storage_layer::lance_clone::create_branch(source, branch, source_version) + .await + .map_err(OmniError::storage)?; + crate::failpoints::maybe_fail(crate::failpoints::names::BRANCH_CREATE_POST_NATIVE)?; + Ok(created) +} + /// Create a branch with a bounded recovery classifier. /// /// The caller must already hold OmniGraph's schema → source/target branch → @@ -226,19 +398,19 @@ pub(crate) async fn create_branch_recoverably( } for attempt in 0..2 { - let native_error = match source - .create_branch(branch, source_version, None) - .await - .map_err(OmniError::storage) - { - Ok(created) => match crate::failpoints::maybe_fail( - crate::failpoints::names::BRANCH_CREATE_POST_NATIVE, - ) { - Ok(()) => return Ok(BranchCreateOutcome::Created(Box::new(created))), + let native_error = + match crate::storage_layer::lance_clone::create_branch(source, branch, source_version) + .await + .map_err(OmniError::storage) + { + Ok(_) => match crate::failpoints::maybe_fail( + crate::failpoints::names::BRANCH_CREATE_POST_NATIVE, + ) { + Ok(()) => return Ok(BranchCreateOutcome::Created), + Err(error) => error, + }, Err(error) => error, - }, - Err(error) => error, - }; + }; let observed = branch_contents(source, branch).await.map_err(|classifier_error| { OmniError::manifest_internal(format!( @@ -259,14 +431,14 @@ pub(crate) async fn create_branch_recoverably( Some(encode_identifier(&contents.identifier)?), )); } - let created = source.checkout_branch(branch).await.map_err(|error| { + 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(Box::new(created))); + return Ok(BranchCreateOutcome::Created); } match reclaim_ref_absent_tree(source, branch).await { @@ -290,97 +462,6 @@ pub(crate) async fn create_branch_recoverably( 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(OmniError::storage) - { - 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::*; @@ -683,8 +764,11 @@ mod tests { )); } + #[cfg(feature = "failpoints")] #[tokio::test] - async fn delete_accepts_absent_authority_and_reclaims_remaining_tree() { + #[serial_test::serial] + async fn retire_classifies_durable_metadata_after_lost_ack_and_retries() { + let _scenario = crate::failpoints::FailScenario::setup(); let dir = tempfile::tempdir().unwrap(); let mut dataset = test_dataset(&dir).await; let version = dataset.version().version; @@ -692,69 +776,59 @@ mod tests { .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) + let mut metadata = HashMap::new(); + metadata.insert("external".to_string(), "preserved".to_string()); + dataset + .branches() + .replace_metadata("feature", metadata) .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")); + let original = dataset.branches().get("feature").await.unwrap(); + { + let _lost_ack = crate::failpoints::ScopedFailPoint::new( + crate::failpoints::names::BRANCH_DELETE_POST_NATIVE, + "return", + ); + retire_branch_recoverably(&dataset, "feature", &original.identifier) + .await + .unwrap(); + } + let retired = dataset.branches().get("feature").await.unwrap(); + assert_eq!(retired.identifier, original.identifier); assert_eq!( - dataset - .list_branches() + retired.metadata.get("external").map(String::as_str), + Some("preserved") + ); + assert!(!manifest_branch_is_live("feature", &retired).unwrap()); + assert!( + list_branch_contents(&dataset) .await .unwrap() - .get("feature") + .contains_key("feature") + ); + assert!( + !list_live_manifest_branch_contents(&dataset) + .await .unwrap() - .identifier, - identifier + .contains_key("feature") + ); + assert!(matches!( + get_live_manifest_branch_contents(&dataset, "feature").await, + Err(OmniError::BranchNotFound { .. }) + )); + let historical = dataset.checkout_branch("feature").await.unwrap(); + assert_eq!(historical.version().version, version); + retire_branch_recoverably(&dataset, "feature", &original.identifier) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(dataset.branches().get("feature").await.unwrap()).unwrap(), + serde_json::to_value(retired).unwrap(), ); } #[tokio::test] - async fn delete_rejects_recreated_identifier_without_removing_it() { + async fn retire_rejects_recreated_identifier_without_removing_it() { let dir = tempfile::tempdir().unwrap(); let mut dataset = test_dataset(&dir).await; let version = dataset.version().version; @@ -785,24 +859,10 @@ mod tests { .clone(); assert_ne!(original, recreated); - let error = delete_branch_recoverably(&mut dataset, "feature", &original) + let error = retire_branch_recoverably(&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!(error.to_string().contains("changed"), "{error}"); assert_eq!( dataset .list_branches() diff --git a/crates/omnigraph/src/branch_names.rs b/crates/omnigraph/src/branch_names.rs index 7f573ad0..edc942f7 100644 --- a/crates/omnigraph/src/branch_names.rs +++ b/crates/omnigraph/src/branch_names.rs @@ -28,6 +28,14 @@ pub(crate) fn native_branch_name(logical: &str, incarnation: &str) -> String { format!("{logical}.{incarnation}") } +/// Name one table fork using the accepted base and the existing commit identity. +/// The name describes an attempt; publication order comes from the manifest. +/// Ownership is recorded in table-version metadata, never inferred from this text. +pub(crate) fn table_fork_name(owner: &str, base_manifest_version: u64, commit_id: &str) -> String { + let incarnation = split_native_branch_name(owner).1.unwrap_or("legacy"); + format!("fork.{incarnation}.m{base_manifest_version}.{commit_id}") +} + fn is_incarnation(candidate: &str) -> bool { candidate.len() == INCARNATION_LEN && candidate.bytes().all(|byte| { @@ -117,6 +125,28 @@ mod tests { assert_eq!(logical_branch_name(&native), "feature/x"); } + /// GQT cannot supply u64::MAX manifest versions or legacy native-owner strings. + #[test] + fn table_fork_names_bound_encoded_paths_for_native_and_legacy_owners() { + let incarnation = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; + let commit = "01ARZ3NDEKTSV4RRFFQ69G5FAW"; + let next_commit = "01ARZ3NDEKTSV4RRFFQ69G5FAX"; + for logical in ["a".repeat(175), "équipe/data/topic".to_string()] { + for owner in [logical.clone(), native_branch_name(&logical, incarnation)] { + let name = table_fork_name(&owner, u64::MAX, commit); + assert!(name.is_ascii()); + assert!(!name.contains('/')); + assert!( + name.len() <= 80, + "bounded ref leaves room for local temporary suffixes" + ); + lance::dataset::refs::check_valid_branch(&name).unwrap(); + assert_eq!(name, table_fork_name(&owner, u64::MAX, commit)); + assert_ne!(name, table_fork_name(&owner, u64::MAX, next_commit)); + } + } + } + #[test] fn legacy_and_lookalike_names_are_not_split() { assert_eq!(split_native_branch_name("feature"), ("feature", None)); diff --git a/crates/omnigraph/src/changes/enumerate.rs b/crates/omnigraph/src/changes/enumerate.rs index 12522a09..8be9afd1 100644 --- a/crates/omnigraph/src/changes/enumerate.rs +++ b/crates/omnigraph/src/changes/enumerate.rs @@ -13,8 +13,6 @@ //! `(entity kind: nodes first, opaque type identity, id, operation rank)`; //! the continuation key inside a page token names a position in that order. -use std::collections::BTreeSet; - use lance::Dataset; use super::candidate_scan::{CandidatePlan, EmitSource}; @@ -314,8 +312,6 @@ async fn plan_intervals( crate::failpoints::maybe_fail(crate::failpoints::names::CHANGE_FEED_PRE_TABLE_OPEN)?; let mut plans = Vec::with_capacity(intervals.len()); - let mut parent_branches: BTreeSet = BTreeSet::new(); - let mut child_branches: BTreeSet = BTreeSet::new(); for interval in intervals { let table_key = interval.type_key(); match (interval.from, interval.to) { @@ -332,12 +328,6 @@ async fn plan_intervals( (Some(from), Some(to)) => { let from_dataset = store.open_at_entry_verified(from).await?; let to_dataset = store.open_at_entry_verified(to).await?; - if let Some(branch) = from.native_dataset_branch.as_deref() { - parent_branches.insert(branch.to_string()); - } - if let Some(branch) = to.native_dataset_branch.as_deref() { - child_branches.insert(branch.to_string()); - } if user_schema_fingerprint(&from_dataset) != user_schema_fingerprint(&to_dataset) { return Err(schema_boundary(graph_commit_id, table_key)); } @@ -374,22 +364,8 @@ async fn plan_intervals( (None, None) => unreachable!("changed intervals have at least one endpoint"), } } - // The load-bearing second-window witness. The per-table opens above happen - // AFTER the commit's manifest-head proof and are keyed only by (branch - // path, numeric version), so a named branch deleted and recreated in that - // window retargets them to the replacement branch's rows. Re-prove each - // opened named branch LOGICALLY after all opens: a fresh, cache-bypassing - // manifest snapshot at the same pinned version must still report the same - // `graph_head` this enumeration's snapshot captured. A recreated fork's - // manifest at that version carries different lineage commit ids (or lacks - // the version entirely), so this fails closed on every store — including - // one that persists no table e_tags; the per-open e_tag comparison is - // defense-in-depth, not the witness. Ordering makes this sound: a - // recreation before any table open is detected here, and a recreation - // after this proof cannot have affected the already-opened handles. Main - // cannot undergo branch-name ABA and pays no extra manifest resolution. - reprove_named_branch_heads(store, parent, &parent_branches).await?; - reprove_named_branch_heads(store, child, &child_branches).await?; + reprove_named_branch_heads(store, parent).await?; + reprove_named_branch_heads(store, child).await?; // Nothing after this witness may read the branch's numeric-path history // live: version manifests sit at replaceable numeric paths (unlike // UUID-named data and transaction files), so a later live read would see a @@ -407,32 +383,24 @@ async fn plan_intervals( Ok(plans) } -/// Fail closed unless every named branch whose tables this enumeration just -/// opened still resolves — via a fresh manifest open at the snapshot's pinned -/// version — to the same `graph_head` the captured snapshot carries. See the -/// call site in [`plan_intervals`] for the window this closes. -async fn reprove_named_branch_heads( - store: &TableStore, - snapshot: &Snapshot, - branches: &BTreeSet, -) -> Result<()> { - for branch in branches { - // `branches` holds native fork refs; graph heads are keyed by the - // logical branch those forks belong to. - let branch = crate::branch_names::logical_branch_name(branch); - let fresh = crate::db::manifest::ManifestCoordinator::snapshot_at( - store.root_uri(), - Some(branch), - snapshot.graph_manifest_version(), - ) - .await?; - if fresh.graph_head(Some(branch)) != snapshot.graph_head(Some(branch)) { - return Err(OmniError::manifest(format!( - "change feed branch '{branch}' has no persisted native-branch \ - incarnation witness after the per-table opens; the branch was \ - deleted and recreated during the poll" - ))); - } +/// Reprove the captured graph branch after table opens, including on stores +/// without table ETags. Table-fork ownership does not identify this graph branch. +async fn reprove_named_branch_heads(store: &TableStore, snapshot: &Snapshot) -> Result<()> { + let Some(branch) = snapshot.graph_branch() else { + return Ok(()); + }; + let fresh = crate::db::manifest::ManifestCoordinator::snapshot_at( + store.root_uri(), + Some(branch), + snapshot.graph_manifest_version(), + ) + .await?; + if fresh.graph_head(Some(branch)) != snapshot.graph_head(Some(branch)) { + return Err(OmniError::manifest(format!( + "change feed branch '{branch}' has no persisted native-branch \ + incarnation witness after the per-table opens; the branch was \ + deleted and recreated during the poll" + ))); } Ok(()) } diff --git a/crates/omnigraph/src/db/graph_coordinator.rs b/crates/omnigraph/src/db/graph_coordinator.rs index 1830ac96..387a21d9 100644 --- a/crates/omnigraph/src/db/graph_coordinator.rs +++ b/crates/omnigraph/src/db/graph_coordinator.rs @@ -377,22 +377,6 @@ impl GraphCoordinator { self.manifest.native_branch() } - /// Capture deletion's native registry and descendants from one listing - /// while the caller holds the schema-control gate. - pub(crate) async fn native_branches_and_descendants( - &self, - name: &str, - ) -> Result<(Vec, Vec)> { - let (natives, descendants) = self.manifest.native_branches_and_descendants(name).await?; - Ok(( - natives, - descendants - .into_iter() - .filter(|branch| !is_internal_system_branch(branch)) - .collect(), - )) - } - pub(crate) async fn branch_create(&mut self, name: &str) -> Result<()> { let branch = normalize_branch_name(name)? .ok_or_else(|| OmniError::manifest("cannot create branch 'main'".to_string()))?; @@ -414,11 +398,6 @@ impl GraphCoordinator { ))); } - // 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 } diff --git a/crates/omnigraph/src/db/manifest.rs b/crates/omnigraph/src/db/manifest.rs index e19f8782..71452748 100644 --- a/crates/omnigraph/src/db/manifest.rs +++ b/crates/omnigraph/src/db/manifest.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use crate::branch_control::list_branch_contents; +use crate::branch_control::list_live_manifest_branch_contents; use crate::error::{OmniError, Result, missing_graph_type_at_snapshot}; use datafusion::logical_expr::Expr; use lance::Dataset; @@ -193,10 +193,10 @@ pub struct Snapshot { /// resolving the head separately (e.g. via `CommitGraph`) could pair this /// snapshot's datasets with a different version's head. graph_heads: HashMap, - /// The native Lance ref this branch snapshot was served from, when it came - /// from a live branch coordinator (`None` on main, for time-travel reads, - /// and for directly built test snapshots). Writers fork tables under this - /// exact name so every fork of one incarnation shares one physical name. + /// Logical graph branch used to capture this snapshot, including historical reads. + graph_branch: Option, + /// Native ref of the live branch coordinator; named writes record it as fork owner. + /// `None` on main, time-travel reads, and directly built test snapshots. native_branch: Option, /// Per-graph read caches (shared `Session` + held-handle cache), injected by /// `Omnigraph::resolved_target` for live Branch reads so dataset opens reuse @@ -214,23 +214,27 @@ pub(crate) struct NativeForkReferences { } impl NativeForkReferences { + #[cfg(test)] pub(crate) fn contains(&self, identity: TableIdentity, native: &str) -> bool { self.referenced.contains(&(identity, native.to_string())) } + pub(crate) fn contains_tree(&self, identity: TableIdentity, tree: &str) -> bool { + self.referenced.iter().any(|(table, native)| { + *table == identity + && (native == tree + || native + .strip_prefix(tree) + .is_some_and(|suffix| suffix.starts_with('/'))) + }) + } + + #[cfg(test)] pub(crate) fn owner_contains(&self, identity: TableIdentity, native: &str) -> bool { self.owned.contains(&(identity, native.to_string())) } } -pub(crate) fn detached_native_lineage_error(table_key: &str, native: &str) -> OmniError { - OmniError::manifest(format!( - "table '{table_key}' has detached native lineage '{native}' still pinned by another graph branch; \ - writing would destroy that branch's history. Create a new branch from this branch's current \ - snapshot and write there" - )) -} - /// Read-only view of one backing dataset pinned by a [`Snapshot`]. /// /// The underlying Lance [`Dataset`] is deliberately private: a snapshot dataset @@ -422,6 +426,10 @@ impl SnapshotDataset { } impl Snapshot { + pub(crate) fn graph_branch(&self) -> Option<&str> { + self.graph_branch.as_deref() + } + /// The native Lance ref this branch snapshot was served from (`None` on /// main, for time-travel reads, and for directly built snapshots). pub(crate) fn native_branch(&self) -> Option<&str> { @@ -649,10 +657,13 @@ async fn probe_dataset_latest_incarnation( .latest_version_id() .await .map_err(|error| branch_ref_error(error, branch))?; - let branch_identifier = dataset - .branch_identifier() - .await - .map_err(|error| branch_ref_error(error, branch))?; + let native = dataset.manifest().branch.as_deref().ok_or_else(|| { + OmniError::manifest_internal("named coordinator has no native manifest ref") + })?; + let branch_identifier = + crate::branch_control::get_live_manifest_branch_contents(dataset, native) + .await? + .identifier; Ok::<_, OmniError>(ManifestIncarnation { version, e_tag: None, @@ -671,7 +682,7 @@ async fn probe_dataset_latest_incarnation( // name through the live registry: the replacement's identity is a // guaranteed mismatch, and a deleted branch is a typed absence. Only a // registry that still names the held ref makes the miss a real failure. - let live = crate::branch_control::list_branch_contents(dataset).await?; + let live = crate::branch_control::list_live_manifest_branch_contents(dataset).await?; let Some(native) = crate::branch_names::resolve_native_branch(live.keys().map(String::as_str), branch)? else { @@ -1031,6 +1042,7 @@ impl ManifestCoordinator { .map(|entry| (entry.type_key.clone(), entry)) .collect(), graph_heads: state.graph_heads, + graph_branch: None, native_branch: None, read_caches: None, } @@ -1229,41 +1241,12 @@ impl ManifestCoordinator { version: u64, ) -> Result { let root = root_uri.trim_end_matches('/'); - Ok(Self::snapshot_from_state( - root, - snapshot_state_at(root, branch, version).await?, - )) - } - - /// Test whether one live graph branch still inherits a table fork from a - /// branch being deleted, without capturing the candidate branch's native - /// incarnation. - /// - /// This deliberately narrow predicate is valid only while the caller holds - /// branch-delete's complete control envelope: the schema-control gate, the - /// delete-target branch gate, and every accepted-catalog table gate for the - /// target. The schema gate serializes native branch create/delete in the - /// supported single-writer process, and an ordinary writer can only replace - /// an inherited `table_branch` with its own branch; it cannot make a live - /// branch newly inherit the held delete target. A raced write can therefore - /// make this snapshot conservatively report an old dependency, never hide a - /// new one. - /// - /// General coordinator, OCC, and live-read/feed opens must not use this - /// path: they need the BranchIdentifier captured with their manifest - /// projection to fence delete/recreate ABA. - pub(super) async fn branch_depends_on_delete_target_under_control_gates( - root_uri: &str, - candidate_native: Option<&str>, - delete_target_native: &str, - control_session: &Arc, - ) -> Result { - let snapshot = - Self::snapshot_native_under_control_gates(root_uri, candidate_native, control_session) - .await?; - Ok(snapshot - .datasets() - .any(|entry| entry.native_dataset_branch.as_deref() == Some(delete_target_native))) + let mut snapshot = + Self::snapshot_from_state(root, snapshot_state_at(root, branch, version).await?); + snapshot.graph_branch = branch + .filter(|branch| *branch != "main") + .map(str::to_string); + Ok(snapshot) } /// Read one exact native manifest ref for a control-plane liveness proof. @@ -1282,10 +1265,12 @@ impl ManifestCoordinator { let dataset = open_manifest_dataset_native_with_session(root, candidate_native, control_session) .await?; - Ok(Self::snapshot_from_state( - root, - read_manifest_state(&dataset).await?, - )) + let mut snapshot = Self::snapshot_from_state(root, read_manifest_state(&dataset).await?); + snapshot.graph_branch = candidate_native + .filter(|branch| *branch != "main") + .map(crate::branch_names::logical_branch_name) + .map(str::to_string); + Ok(snapshot) } /// Prove native-table liveness from main and every live branch, including @@ -1298,7 +1283,7 @@ impl ManifestCoordinator { ) -> Result { let root = root_uri.trim_end_matches('/'); let main = open_manifest_dataset_native_with_session(root, None, control_session).await?; - let mut branches: Vec<_> = list_branch_contents(&main) + let mut branches: Vec<_> = list_live_manifest_branch_contents(&main) .await? .into_keys() .filter(|name| name != "main") @@ -1313,7 +1298,9 @@ impl ManifestCoordinator { if let Some(table_native) = entry.native_dataset_branch.as_deref() { let key = (entry.identity, table_native.to_string()); references.referenced.insert(key.clone()); - if native == Some(table_native) { + if native.is_some_and(|owner| { + entry.version_metadata.is_table_fork_of(table_native, owner) + }) { references.owned.insert(key); } } @@ -1333,10 +1320,21 @@ impl ManifestCoordinator { Ok(references) } + /// Inventory registered table lifetimes, including soft-dropped tables, under cleanup's gates. + pub(crate) async fn table_registrations_under_control_gates( + root_uri: &str, + control_session: &Arc, + ) -> Result> { + let main = + open_manifest_dataset_native_with_session(root_uri, None, control_session).await?; + state::read_manifest_table_registrations(&main).await + } + /// Return a Snapshot from the known manifest state. No storage I/O. pub fn snapshot(&self) -> Snapshot { let mut snapshot = Self::snapshot_from_state(&self.root_uri, self.known_state.clone()); snapshot.native_branch = self.native_branch.clone(); + snapshot.graph_branch = self.active_branch.clone(); snapshot } @@ -1805,7 +1803,7 @@ impl ManifestCoordinator { pub(crate) async fn create_branch(&mut self, name: &str) -> Result<()> { crate::branch_names::ensure_logical_branch_name(name)?; let mut ds = self.dataset.clone(); - let live = list_branch_contents(&ds).await?; + let live = list_live_manifest_branch_contents(&ds).await?; if crate::branch_names::resolve_native_branch(live.keys().map(String::as_str), name)? .is_some() { @@ -1819,7 +1817,7 @@ impl ManifestCoordinator { match crate::branch_control::create_branch_recoverably(&mut ds, &native, self.version()) .await? { - crate::branch_control::BranchCreateOutcome::Created(_) => Ok(()), + crate::branch_control::BranchCreateOutcome::Created => Ok(()), crate::branch_control::BranchCreateOutcome::RefAlreadyExists => Err( OmniError::manifest_conflict(format!("branch '{}' already exists", name)), ), @@ -1838,8 +1836,8 @@ impl ManifestCoordinator { } pub(crate) async fn delete_branch(&mut self, name: &str) -> Result<()> { - let mut ds = self.open_branch_control_dataset().await?; - let branches = list_branch_contents(&ds).await?; + let ds = self.open_branch_control_dataset().await?; + let branches = list_live_manifest_branch_contents(&ds).await?; let native = crate::branch_names::resolve_native_branch(branches.keys().map(String::as_str), name)? .ok_or_else(|| { @@ -1850,7 +1848,7 @@ impl ManifestCoordinator { .ok_or_else(|| OmniError::manifest_not_found(format!("branch '{}' not found", name)))? .identifier .clone(); - crate::branch_control::delete_branch_recoverably(&mut ds, &native, &expected_identifier) + crate::branch_control::retire_branch_recoverably(&ds, &native, &expected_identifier) .await?; Ok(()) } @@ -1868,16 +1866,15 @@ impl ManifestCoordinator { name: &str, expected_identifier: &lance::dataset::refs::BranchIdentifier, ) -> Result<()> { - let mut ds = self.open_branch_control_dataset().await?; + let ds = self.open_branch_control_dataset().await?; let native = resolve_native_manifest_branch(&ds, name).await?; - crate::branch_control::delete_branch_recoverably(&mut ds, &native, expected_identifier) - .await + crate::branch_control::retire_branch_recoverably(&ds, &native, expected_identifier).await } /// Logical graph branches, `main` first. Each live native ref maps to /// exactly one logical name; a duplicate incarnation fails loudly. pub async fn list_graph_branches(&self) -> Result> { - let branches = list_branch_contents(&self.dataset).await?; + let branches = list_live_manifest_branch_contents(&self.dataset).await?; let mut names = Vec::with_capacity(branches.len()); let mut seen = HashSet::with_capacity(branches.len()); for native in branches.keys().filter(|name| *name != "main") { @@ -1895,57 +1892,6 @@ impl ManifestCoordinator { all.extend(names); Ok(all) } - - /// One operation-local branch listing for deletion's namespace and - /// ancestry checks. The caller holds the schema-control gate, so no native - /// branch create/delete can intervene in the supported control envelope. - pub(crate) async fn native_branches_and_descendants( - &self, - name: &str, - ) -> Result<(Vec, Vec)> { - let branches = list_branch_contents(&self.dataset).await?; - let descendants = Self::descendants_from_branch_contents(name, &branches)?; - let mut natives = branches - .into_keys() - .filter(|native| native != "main") - .collect::>(); - natives.sort(); - Ok((natives, descendants)) - } - - /// Walk Lance's native parents and return logical descendant names. - fn descendants_from_branch_contents( - name: &str, - branches: &HashMap, - ) -> Result> { - let Some(native) = - crate::branch_names::resolve_native_branch(branches.keys().map(String::as_str), name)? - else { - return Ok(Vec::new()); - }; - let mut frontier = vec![native]; - let mut descendants = Vec::new(); - let mut seen = HashSet::new(); - - while let Some(parent) = frontier.pop() { - let mut children = branches - .iter() - .filter_map(|(branch, contents)| { - (contents.parent_branch.as_deref() == Some(parent.as_str())) - .then_some(branch.clone()) - }) - .collect::>(); - children.sort(); - for child in children { - if seen.insert(child.clone()) { - descendants.push(crate::branch_names::logical_branch_name(&child).to_string()); - frontier.push(child); - } - } - } - - Ok(descendants) - } } #[cfg(test)] diff --git a/crates/omnigraph/src/db/manifest/graph.rs b/crates/omnigraph/src/db/manifest/graph.rs index 04fa5aca..53661e64 100644 --- a/crates/omnigraph/src/db/manifest/graph.rs +++ b/crates/omnigraph/src/db/manifest/graph.rs @@ -142,6 +142,9 @@ pub(super) async fn init_manifest_graph( session: Some(Arc::clone(control_session)), ..Default::default() }; + let params = crate::storage_layer::lance_clone::write_params(&manifest_path, params) + .await + .map_err(OmniError::storage)?; let dataset = Dataset::write(reader, &manifest_path, Some(params)) .await .map_err(|e| ManifestInitError::ManifestCreateOutcomeUnknown(OmniError::storage(e)))?; @@ -406,6 +409,9 @@ async fn create_empty_dataset( session: Some(Arc::clone(control_session)), ..Default::default() }; + let params = crate::storage_layer::lance_clone::write_params(uri, params) + .await + .map_err(OmniError::storage)?; let dataset = Dataset::write(reader, uri, Some(params)) .await .map_err(OmniError::storage)?; diff --git a/crates/omnigraph/src/db/manifest/layout.rs b/crates/omnigraph/src/db/manifest/layout.rs index c13a4d05..82dadeae 100644 --- a/crates/omnigraph/src/db/manifest/layout.rs +++ b/crates/omnigraph/src/db/manifest/layout.rs @@ -37,7 +37,7 @@ pub(super) async fn resolve_native_manifest_branch( dataset: &Dataset, logical: &str, ) -> Result { - let branches = crate::branch_control::list_branch_contents(dataset).await?; + let branches = crate::branch_control::list_live_manifest_branch_contents(dataset).await?; crate::branch_names::resolve_native_branch(branches.keys().map(String::as_str), logical)? .ok_or_else(|| OmniError::BranchNotFound { branch: logical.to_string(), diff --git a/crates/omnigraph/src/db/manifest/metadata.rs b/crates/omnigraph/src/db/manifest/metadata.rs index 1fe2382b..8f19d90a 100644 --- a/crates/omnigraph/src/db/manifest/metadata.rs +++ b/crates/omnigraph/src/db/manifest/metadata.rs @@ -14,6 +14,7 @@ use super::layout::table_id_to_key; pub(super) const OMNIGRAPH_ROW_COUNT_KEY: &str = "omnigraph.row_count"; const OMNIGRAPH_TABLE_BRANCH_KEY: &str = "omnigraph.table_branch"; +const OMNIGRAPH_TABLE_FORK_OWNER_KEY: &str = "omnigraph.table_fork_owner"; pub(super) fn namespace_version_metadata( row_count: u64, @@ -54,6 +55,7 @@ pub(super) fn parse_namespace_version_request( manifest_size: request.manifest_size.map(|size| size as u64), e_tag: request.e_tag.clone(), naming_scheme: request.naming_scheme.clone(), + table_fork_owner: metadata.get(OMNIGRAPH_TABLE_FORK_OWNER_KEY).cloned(), }; Ok(( @@ -71,6 +73,8 @@ pub(crate) struct TableVersionMetadata { manifest_size: Option, e_tag: Option, naming_scheme: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + table_fork_owner: Option, } impl TableVersionMetadata { @@ -88,9 +92,25 @@ impl TableVersionMetadata { manifest_size: dataset.manifest_location().size, e_tag: dataset.manifest_location().e_tag.clone(), naming_scheme: Some(format!("{:?}", dataset.manifest_location().naming_scheme)), + table_fork_owner: None, }) } + pub(crate) fn table_fork_owner(&self) -> Option<&str> { + self.table_fork_owner.as_deref() + } + + pub(crate) fn with_table_fork_owner(mut self, owner: Option<&str>) -> Self { + self.table_fork_owner = owner.map(str::to_string); + self + } + + pub(crate) fn is_table_fork_of(&self, fork: &str, owner: &str) -> bool { + self.table_fork_owner + .as_deref() + .map_or(fork == owner, |recorded| recorded == owner) + } + pub(super) fn from_json_str(value: &str) -> Result { serde_json::from_str(value).map_err(|e| { OmniError::manifest_internal(format!("failed to decode manifest metadata: {e}")) @@ -135,7 +155,11 @@ impl TableVersionMetadata { request.manifest_size = self.manifest_size.map(|size| size as i64); request.e_tag = self.e_tag.clone(); request.naming_scheme = self.naming_scheme.clone(); - request.metadata = Some(namespace_version_metadata(row_count, table_branch)); + let mut metadata = namespace_version_metadata(row_count, table_branch); + if let Some(owner) = &self.table_fork_owner { + metadata.insert(OMNIGRAPH_TABLE_FORK_OWNER_KEY.to_string(), owner.clone()); + } + request.metadata = Some(metadata); request } @@ -152,6 +176,9 @@ impl TableVersionMetadata { metadata: Option>, ) -> TableVersion { let mut metadata = metadata.unwrap_or_default(); + if let Some(owner) = &self.table_fork_owner { + metadata.insert(OMNIGRAPH_TABLE_FORK_OWNER_KEY.to_string(), owner.clone()); + } if let Some(naming_scheme) = &self.naming_scheme { metadata.insert("naming_scheme".to_string(), naming_scheme.clone()); } @@ -280,3 +307,128 @@ pub(super) async fn table_version_metadata_for_state( .map_err(OmniError::storage)?; TableVersionMetadata::from_dataset(root_uri, table_path, &ds) } + +#[cfg(test)] +mod tests { + use super::*; + + const LEGACY_JSON: &str = r#"{ + "manifest_path":"graph/nodes/person/_versions/7.manifest", + "manifest_size":321, + "e_tag":"version-etag", + "naming_scheme":"V2" + }"#; + const OWNER: &str = "source.01ARZ3NDEKTSV4RRFFQ69G5FAV"; + const TARGET: &str = "target.01ARZ3NDEKTSV4RRFFQ69G5FAW"; + const FORK: &str = "fork.01ARZ3NDEKTSV4RRFFQ69G5FAV.m42.01ARZ3NDEKTSV4RRFFQ69G5FAX"; + + /// GQT cannot inject absent owner metadata or inspect its serialized omission. + #[test] + fn legacy_metadata_owns_only_the_exact_native_ref() { + let metadata = TableVersionMetadata::from_json_str(LEGACY_JSON).unwrap(); + assert_eq!(metadata.table_fork_owner(), None); + for legacy_ref in ["foo", OWNER, "foo.m42.01ARZ3NDEKTSV4RRFFQ69G5FAV"] { + assert!(metadata.is_table_fork_of(legacy_ref, legacy_ref)); + assert!(!metadata.is_table_fork_of(legacy_ref, TARGET)); + } + assert!(!metadata.is_table_fork_of("foo.m42.01ARZ3NDEKTSV4RRFFQ69G5FAV", "foo")); + assert!(!metadata.is_table_fork_of(FORK, OWNER)); + let encoded = metadata.to_json_string().unwrap(); + let value: serde_json::Value = serde_json::from_str(&encoded).unwrap(); + assert!(value.get("table_fork_owner").is_none()); + assert_eq!( + TableVersionMetadata::from_json_str(&encoded).unwrap(), + metadata + ); + } + + /// GQT cannot forge legacy refs and metadata markers independently. + #[test] + fn explicit_owner_distinguishes_a_new_fork_from_a_legacy_lookalike() { + let legacy = TableVersionMetadata::from_json_str(LEGACY_JSON).unwrap(); + let marked = legacy.clone().with_table_fork_owner(Some("foo")); + let lookalike = "foo.m42.01ARZ3NDEKTSV4RRFFQ69G5FAV"; + assert!(!legacy.is_table_fork_of(lookalike, "foo")); + assert!(marked.is_table_fork_of(lookalike, "foo")); + assert!(!marked.is_table_fork_of(lookalike, lookalike)); + assert!(!marked.is_table_fork_of(lookalike, TARGET)); + } + + /// GQT does not expose physical version metadata serialization. + #[test] + fn owner_metadata_json_roundtrip_preserves_physical_version_fields() { + let metadata = TableVersionMetadata::from_json_str(LEGACY_JSON) + .unwrap() + .with_table_fork_owner(Some(OWNER)); + let encoded = metadata.to_json_string().unwrap(); + let decoded = TableVersionMetadata::from_json_str(&encoded).unwrap(); + assert_eq!(decoded, metadata); + assert_eq!(decoded.table_fork_owner(), Some(OWNER)); + assert!(decoded.is_table_fork_of(FORK, OWNER)); + assert!(!decoded.is_table_fork_of(FORK, TARGET)); + + let main = decoded.with_table_fork_owner(None); + assert_eq!( + main, + TableVersionMetadata::from_json_str(LEGACY_JSON).unwrap() + ); + } + + /// GQT does not expose the namespace registration request and response metadata. + #[test] + fn namespace_pointer_roundtrip_keeps_source_ownership() { + let source = TableVersionMetadata::from_json_str(LEGACY_JSON) + .unwrap() + .with_table_fork_owner(Some(OWNER)); + let request = source.to_create_table_version_request("node:Person", 7, 3, Some(FORK)); + assert_eq!( + request + .metadata + .as_ref() + .unwrap() + .get(OMNIGRAPH_TABLE_FORK_OWNER_KEY) + .map(String::as_str), + Some(OWNER) + ); + let (table_key, version, rows, native_ref, target_registration) = + parse_namespace_version_request(&request).unwrap(); + assert_eq!(table_key, "node:Person"); + assert_eq!(version, 7); + assert_eq!(rows, 3); + assert_eq!(native_ref.as_deref(), Some(FORK)); + assert_eq!(target_registration, source); + assert!(target_registration.is_table_fork_of(FORK, OWNER)); + assert!(!target_registration.is_table_fork_of(FORK, TARGET)); + + let response = target_registration.to_namespace_version_with_details( + 7, + Some(123), + Some(HashMap::from([( + "producer".to_string(), + "test".to_string(), + )])), + ); + let metadata = response.metadata.unwrap(); + assert_eq!( + metadata + .get(OMNIGRAPH_TABLE_FORK_OWNER_KEY) + .map(String::as_str), + Some(OWNER) + ); + assert_eq!(metadata.get("producer").map(String::as_str), Some("test")); + } + + /// GQT cannot inject malformed persisted owner JSON types. + #[test] + fn malformed_owner_json_types_are_rejected() { + for owner in [ + serde_json::json!(42), + serde_json::json!({"name": OWNER}), + serde_json::json!([OWNER]), + ] { + let mut value: serde_json::Value = serde_json::from_str(LEGACY_JSON).unwrap(); + value["table_fork_owner"] = owner; + assert!(TableVersionMetadata::from_json_str(&value.to_string()).is_err()); + } + } +} diff --git a/crates/omnigraph/src/db/manifest/migrations.rs b/crates/omnigraph/src/db/manifest/migrations.rs index 9ab15a03..9d266570 100644 --- a/crates/omnigraph/src/db/manifest/migrations.rs +++ b/crates/omnigraph/src/db/manifest/migrations.rs @@ -16,7 +16,7 @@ //! ## Explicit conversion and normal-open contract //! //! Normal open accepts only CURRENT and refuses an active storage-upgrade intent. -//! The explicit offline upgrade entry point converts supported v6 graphs before +//! The explicit offline upgrade entry point converts supported v6/v7 graphs before //! serving. Retained v6 snapshots use the legacy decoder after root admission; //! normal open never runs conversion or lowers MIN_SUPPORTED. //! Fresh graphs receive their stamp atomically in the manifest Create commit. @@ -57,9 +57,12 @@ use crate::error::{OmniError, Result}; /// greatest per-native-ref Lance version. The unreleased v7–v19 stamps of the /// rejected MemWAL experiment never shipped; v7 is reused. /// -/// v1–v6 graphs are not served by this binary (see `MIN_SUPPORTED`); the history +/// - v8 — native graph refs retain ancestry through versioned retirement metadata. +/// Old readers must refuse rather than expose retired refs as live branches. +/// +/// v1–v7 graphs are not served by this binary (see `MIN_SUPPORTED`); the history /// is kept for provenance and to document what each stamp value meant. -pub(crate) const INTERNAL_MANIFEST_SCHEMA_VERSION: u32 = 7; +pub(crate) const INTERNAL_MANIFEST_SCHEMA_VERSION: u32 = 8; /// The oldest main-manifest stamp accepted by normal open. /// Explicit conversion and retained-snapshot decoding do not lower this gate. @@ -84,6 +87,7 @@ pub(crate) fn release_for_internal_schema_version(stamp: u32) -> &'static str { "built from unreleased final-v5 source commit 46b6d9084fb629b88d4ac9e8c546e0a30d213d19" } 6 => "0.9.x or 0.10.x", + 7 => "an unreleased v7 development build", // Unreachable today (1–6 are mapped; > CURRENT is caught by the ceiling // guard before this is consulted). Worded to read naturally after // "created by omnigraph " if a future bump ever leaves a gap. @@ -143,9 +147,9 @@ pub(crate) fn guard_stamp(dataset: &Dataset) -> Result { .metadata .contains_key(super::upgrade::UPGRADE_PENDING_KEY) { - return Err(OmniError::manifest( - "storage upgrade recovery required: stop all writers and maintenance, then rerun the same `omnigraph upgrade --to-format 7` command with the upgrade-capable executable", - )); + return Err(OmniError::manifest(super::upgrade::recovery_guidance( + dataset, + ))); } match dataset.schema().metadata.get(INTERNAL_SCHEMA_VERSION_KEY) { Some(value) => match value.parse::() { @@ -203,8 +207,8 @@ pub(crate) fn refuse_if_stamp_unsupported(stamp: u32) -> Result<()> { ))); } if stamp < MIN_SUPPORTED_INTERNAL_SCHEMA_VERSION { - let explicit_upgrade = if stamp == 6 { - " A registered in-place route is also available: stop all writers and maintenance, retain a verified backup, and run `omnigraph upgrade --check --to-format 7` before execution." + let explicit_upgrade = if matches!(stamp, 6 | 7) { + " A registered in-place route is also available: stop all writers and maintenance, retain a verified backup, and run `omnigraph upgrade --check --to-format 8` before execution." } else { "" }; @@ -276,8 +280,8 @@ mod tests { use super::*; /// The guard accepts exactly the single served version and refuses anything - /// below the floor or above the ceiling. With `MIN == CURRENT == 7` the live - /// range is exactly `[7, 7]`. + /// below the floor or above the ceiling. With `MIN == CURRENT == 8` the live + /// range is exactly `[8, 8]`. #[test] fn unsupported_guard_accepts_exactly_the_supported_range() { for stamp in MIN_SUPPORTED_INTERNAL_SCHEMA_VERSION..=INTERNAL_MANIFEST_SCHEMA_VERSION { @@ -294,10 +298,10 @@ mod tests { } let future_stamp = INTERNAL_MANIFEST_SCHEMA_VERSION + 1; let future = refuse_if_stamp_unsupported(future_stamp) - .expect_err("the first abandoned post-v7 stamp must be refused") + .expect_err("the first unsupported future stamp must be refused") .to_string(); - assert!(future.contains("internal schema v8"), "got: {future}"); - assert!(future.contains("expects v7"), "got: {future}"); + assert!(future.contains("internal schema v9"), "got: {future}"); + assert!(future.contains("expects v8"), "got: {future}"); assert!(future.contains("upgrade omnigraph"), "got: {future}"); } diff --git a/crates/omnigraph/src/db/manifest/publisher.rs b/crates/omnigraph/src/db/manifest/publisher.rs index d8ebf690..ca3afe09 100644 --- a/crates/omnigraph/src/db/manifest/publisher.rs +++ b/crates/omnigraph/src/db/manifest/publisher.rs @@ -892,16 +892,24 @@ impl GraphNamespacePublisher { "failed to encode expected Lance branch identifier: {e}" )) })?; - let actual_branch_identifier = match dataset.branch_identifier().await { + let observed_identifier = match dataset.manifest().branch.as_deref() { + Some(native) => { + crate::branch_control::get_live_manifest_branch_contents(dataset, native) + .await + .map(|contents| contents.identifier) + } + None => Ok(lance::dataset::refs::BranchIdentifier::main()), + }; + let actual_branch_identifier = match observed_identifier { Ok(identifier) => identifier, - Err(LanceError::RefNotFound { .. }) => { + Err(OmniError::BranchNotFound { .. }) => { return Err(OmniError::manifest_read_set_changed( branch_identity_member, Some(expected_branch_identifier), None, )); } - Err(err) => return Err(OmniError::storage(err)), + Err(err) => return Err(err), }; if actual_branch_identifier != expected.branch_identifier { let actual = serde_json::to_string(&actual_branch_identifier).map_err(|e| { diff --git a/crates/omnigraph/src/db/manifest/recovery.rs b/crates/omnigraph/src/db/manifest/recovery.rs index b29d78ce..92d1293f 100644 --- a/crates/omnigraph/src/db/manifest/recovery.rs +++ b/crates/omnigraph/src/db/manifest/recovery.rs @@ -505,6 +505,8 @@ pub(crate) struct SidecarTablePin { /// compatibility with older sidecars; `None` means main / default. #[serde(default, skip_serializing_if = "Option::is_none")] pub table_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub table_fork_owner: Option, } /// New-table registration captured by SchemaApply sidecars so recovery @@ -1371,7 +1373,91 @@ pub(crate) fn parse_sidecar(sidecar_uri: &str, body: &str) -> Result Result<()> { + for pin in &sidecar.tables { + if let Some(owner) = pin.table_fork_owner.as_deref() { + if owner == "main" + || lance::dataset::refs::check_valid_branch(owner).is_err() + || sidecar.branch.as_deref() + != Some(crate::branch_names::logical_branch_name(owner)) + || pin + .table_branch + .as_deref() + .is_none_or(|branch| branch == "main") + { + return Err(OmniError::manifest_internal(format!( + "sidecar '{}' table '{}' has invalid table-fork owner '{}' for target {:?}", + sidecar.operation_id, pin.table_key, owner, sidecar.branch + ))); + } + } + } + let deltas = [ + sidecar + .protocol_v3 + .as_ref() + .map(|protocol| &protocol.intended_delta), + sidecar + .protocol_v4 + .as_ref() + .map(|protocol| &protocol.intended_delta), + sidecar + .protocol_v7 + .as_ref() + .map(|protocol| &protocol.intended_delta), + sidecar + .protocol_v8 + .as_ref() + .map(|protocol| &protocol.intended_delta), + ]; + for delta in deltas.into_iter().flatten() { + for slot in &delta.table_updates { + let Some(confirmed) = slot.confirmed.as_ref() else { + continue; + }; + let Some(pin) = sidecar + .tables + .iter() + .find(|pin| pin.identity == slot.identity) + else { + continue; + }; + if confirmed.version_metadata.table_fork_owner() != pin.table_fork_owner.as_deref() { + return Err(OmniError::manifest_internal(format!( + "sidecar '{}' table '{}' confirmed owner {:?} differs from physical pin owner {:?}", + sidecar.operation_id, + pin.table_key, + confirmed.version_metadata.table_fork_owner(), + pin.table_fork_owner + ))); + } + } + } + Ok(()) +} + +fn validate_table_fork_owner_snapshot( + snapshot: &Snapshot, + sidecar: &RecoverySidecar, +) -> Result<()> { + for pin in &sidecar.tables { + if let Some(owner) = pin.table_fork_owner.as_deref() { + if snapshot.native_branch() != Some(owner) { + return Err(OmniError::manifest_internal(format!( + "sidecar '{}' table '{}' owner '{}' differs from captured target incarnation {:?}; leaving sidecar pending", + sidecar.operation_id, + pin.table_key, + owner, + snapshot.native_branch() + ))); + } + } + } + Ok(()) +} + fn validate_sidecar_shape(sidecar_uri: &str, sidecar: &RecoverySidecar) -> Result<()> { + validate_table_fork_owners(sidecar)?; let malformed = |reason: String| { OmniError::manifest_internal(format!( "recovery sidecar at '{}' has an invalid schema-v{} shape: {}", @@ -2006,7 +2092,9 @@ fn validate_ensure_indices_v8_shape(sidecar_uri: &str, sidecar: &RecoverySidecar // Pins name the native fork ref; the sidecar names the // logical branch it belongs to. branch != "main" - && Some(crate::branch_names::logical_branch_name(branch)) == sidecar_branch + && Some(crate::branch_names::logical_branch_name( + pin.table_fork_owner.as_deref().unwrap_or(branch), + )) == sidecar_branch })) || effect .source_fork_version @@ -3182,16 +3270,9 @@ pub(crate) async fn heal_pending_sidecars_roll_forward( }) } -/// Discard a sidecar whose branch no longer exists in the manifest (the -/// authority — callers must key the orphan classification off the branch -/// LIST, never off a `Not found` from an open, which could be a transient -/// storage error masking real recovery intent). The branch's tree and -/// per-table forks are already reclaimed, so the drift the sidecar pins is -/// unreachable and the sidecar is provably moot; leaving it would wedge -/// every heal (write entry) and every ReadWrite open on a dead-branch -/// open, with `repair` refusing while it pends. Records an -/// `OrphanedBranchDiscarded` audit row (lineage published on main — the -/// sidecar's own branch no longer has a live graph head). +/// Retire intent only after the authoritative branch list proves its graph branch absent. +/// Record `OrphanedBranchDiscarded` on main; a failed table open is not absence proof. +/// Abandoned table forks remain for explicit cleanup. async fn discard_orphaned_branch_sidecar( root_uri: &str, storage: &dyn StorageAdapter, @@ -3467,6 +3548,7 @@ async fn classify_sidecar_tables( snapshot: &Snapshot, sidecar: &RecoverySidecar, ) -> Result> { + validate_table_fork_owner_snapshot(snapshot, sidecar)?; let mut states = Vec::with_capacity(sidecar.tables.len()); for pin in &sidecar.tables { let manifest_entry = snapshot_entry_for_pin(snapshot, pin)?; @@ -3488,8 +3570,9 @@ async fn classify_sidecar_tables( .is_some_and(|branch| branch != "main") && sidecar.branch.as_deref() == pin - .table_branch + .table_fork_owner .as_deref() + .or(pin.table_branch.as_deref()) .map(crate::branch_names::logical_branch_name) && manifest_entry .map(|entry| entry.native_dataset_branch != pin.table_branch) @@ -3588,10 +3671,6 @@ pub(crate) async fn finalize_effect_free_occ_sidecar( return Ok(false); } - match cleanup_unpublished_no_effect_forks(root_uri, storage, sidecar, &states).await? { - NoEffectForkCleanup::Complete => {} - NoEffectForkCleanup::DeferredPathChild { .. } => return Ok(false), - } delete_sidecar_by_operation_id(root_uri, storage, &sidecar.operation_id).await?; Ok(true) } @@ -3739,39 +3818,10 @@ async fn process_sidecar( } if !any_own_effect { - // The sidecar was armed but this writer never landed a physical - // effect. RollForwardOnly recovery may be looking at a LIVE writer, - // so an Armed sidecar is presumed ownership; the one exception is - // the identity-aware retirement below, which proves the intent - // effect-free before touching it. A quiesced Full sweep may - // abandon it; first remove only exact, unpublished first-touch - // forks that this intent owns. Do not manufacture lineage for an - // empty intent and never restore a foreign advance. if matches!(mode, RecoveryMode::RollForwardOnly) && (protocol.effect_phase == RecoveryEffectPhase::Armed || states.iter().any(|state| state.unpublished_fork)) { - // A dead writer's Armed mutation/load intent would otherwise - // pend until the next ReadWrite open, which a long-lived - // server never performs (issue #554). The caller holds this - // sidecar's full schema -> branch -> table gate envelope, so - // no in-process writer can own it; exact transaction-identity - // finalization retires it only when provably effect-free, the - // same one-mutation-process boundary destructive Full - // recovery already assumes (docs/dev/invariants.md, current - // support boundaries). Exclusions that keep deferring to the - // next ReadWrite open: pre-v9 sidecars and non-mutation/load - // kinds (no exact identity to prove effect-freedom with — - // finalization ERRORS on both rather than declining, so those - // gates must run here) and first-touch forks (reclaiming an - // unpublished target ref requires the quiescence - // `cleanup_unpublished_no_effect_forks` documents). The Armed - // conjunct is defense in depth: the enclosing branch plus the - // fork exclusion already force Armed, and finalization - // re-checks it. An Armed EnsureIndices intent wedges the same - // way but carries no exact-effect identity; its live - // retirement needs its own safety argument and is - // deliberately not folded in here. if sidecar.schema_version == IDENTITY_AWARE_SIDECAR_SCHEMA_VERSION && matches!( sidecar.writer_kind, @@ -3815,30 +3865,6 @@ async fn process_sidecar( ); return Ok(false); } - if matches!(mode, RecoveryMode::Full) { - 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(), authority_changed, "recovery: abandoning v3 sidecar with no owned physical effects" @@ -3963,18 +3989,6 @@ async fn process_sidecar( if matches!(mode, RecoveryMode::RollForwardOnly) { return Ok(false); } - if matches!( - cleanup_unpublished_no_effect_forks( - root_uri, - storage.as_ref(), - sidecar, - &states, - ) - .await?, - NoEffectForkCleanup::DeferredPathChild { .. } - ) { - return Ok(false); - } delete_sidecar_by_operation_id(root_uri, storage.as_ref(), &sidecar.operation_id) .await?; return Ok(true); @@ -4516,6 +4530,7 @@ async fn process_ensure_indices_sidecar_v8( if let Some(outcome) = detect_visible_v8_outcome(root_uri, sidecar).await? { return finalize_visible_v8_outcome(root_uri, storage.as_ref(), sidecar, outcome).await; } + validate_table_fork_owner_snapshot(snapshot, sidecar)?; let protocol = sidecar .protocol_v8 .as_ref() @@ -4775,181 +4790,13 @@ async fn roll_back_ensure_indices_v8( storage: &dyn StorageAdapter, sidecar: &RecoverySidecar, states: &[ClassifiedTable], - snapshot: &Snapshot, + _snapshot: &Snapshot, ) -> Result<()> { - // Remove untouched first-touch refs before freezing the audit plan. A - // path-child overlap cannot be left behind once this rollback also owns a - // table effect, because a successful open would expose unresolved state. - 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!( - "EnsureIndices sidecar '{}' cannot clean first-touch '{}:{}' while path-child '{}' is live", - sidecar.operation_id, table_path, target_branch, path_child - ))); - } - - // Persist the original observations before deleting a first-touch ref or - // restoring an existing one. Re-entry after either physical action reuses - // this exact operator-facing outcome set. let prepared = prepare_fixed_rollback_audit_plan(root_uri, storage, sidecar, states).await?; let protocol = prepared .protocol_v8 .as_ref() .expect("prepared schema-v8 protocol"); - let all_sidecars = list_sidecars(root_uri, storage).await?; - let fork_references = if protocol - .effects - .iter() - .any(|effect| effect.source_fork_version.is_some()) - { - Some( - Box::pin( - super::ManifestCoordinator::native_fork_references_under_control_gates( - root_uri, - &crate::lance_access::control_session(), - ), - ) - .await?, - ) - } else { - None - }; - - for ((pin, state), effect) in prepared.tables.iter().zip(states.iter()).map(|pair| { - let effect = protocol - .effects - .iter() - .find(|effect| effect.identity == pair.0.identity) - .expect("validated schema-v8 key sets"); - (pair, effect) - }) { - let Some(source_fork_version) = effect.source_fork_version else { - continue; - }; - if snapshot_entry_by_identity(snapshot, pin.identity) - .is_some_and(|entry| entry.native_dataset_branch == pin.table_branch) - { - return Err(OmniError::manifest_internal(format!( - "EnsureIndices sidecar '{}' cannot reclaim first-touch ref for '{}' because the manifest now owns it without the fixed original lineage", - prepared.operation_id, pin.table_key - ))); - } - let Some(target_branch) = pin - .table_branch - .as_deref() - .filter(|branch| *branch != "main") - else { - return Err(OmniError::manifest_internal(format!( - "EnsureIndices first-touch table '{}' has no named target ref", - pin.table_key - ))); - }; - if fork_references - .as_ref() - .is_some_and(|references| references.contains(pin.identity, target_branch)) - { - return Err(OmniError::manifest_internal(format!( - "EnsureIndices sidecar '{}' cannot reclaim first-touch '{}:{}' while a live graph snapshot pins it", - prepared.operation_id, pin.table_path, target_branch - ))); - } - if all_sidecars.iter().any(|candidate| { - candidate.operation_id != prepared.operation_id - && candidate.tables.iter().any(|candidate_pin| { - candidate_pin.table_path == pin.table_path - && candidate_pin.table_branch.as_deref() == Some(target_branch) - }) - }) { - return Err(OmniError::manifest_internal(format!( - "EnsureIndices sidecar '{}' cannot reclaim first-touch '{}:{}' while another recovery intent claims it", - prepared.operation_id, pin.table_path, target_branch - ))); - } - - let mut dataset = crate::instrumentation::open_dataset( - &pin.table_path, - crate::instrumentation::VersionResolution::Latest, - None, - crate::instrumentation::table_wrapper(), - ) - .await?; - let branches = list_branch_contents(&dataset).await?; - if let Some(child) = crate::branch_control::path_descendant(&branches, target_branch) { - return Err(OmniError::manifest_internal(format!( - "EnsureIndices sidecar '{}' cannot reclaim first-touch '{}:{}' while path-child '{}' is live", - prepared.operation_id, pin.table_path, target_branch, child - ))); - } - let Some(contents) = branches.get(target_branch) else { - // Either the writer crashed before ref creation or a prior recovery - // deleted the exact owned ref and crashed before manifest publish. - 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 EnsureIndices rollback" - ))); - } - continue; - }; - if contents.parent_version != source_fork_version { - return Err(OmniError::manifest_internal(format!( - "EnsureIndices sidecar '{}' first-touch '{}:{}' has parent {}, expected {}", - prepared.operation_id, - pin.table_path, - target_branch, - contents.parent_version, - source_fork_version - ))); - } - if let Some(expected_identifier) = effect.confirmed_branch_identifier.as_ref() - && &contents.identifier != expected_identifier - { - return Err(OmniError::manifest_internal(format!( - "EnsureIndices sidecar '{}' cannot reclaim first-touch '{}:{}' because its ref identity changed", - prepared.operation_id, pin.table_path, target_branch - ))); - } - - match state.effect_ownership { - EffectOwnership::OwnAtHead | EffectOwnership::OwnCompensatedAtHead => { - let target = dataset - .checkout_branch(target_branch) - .await - .map_err(OmniError::storage)?; - if target.version().version != state.lance_head - || !prove_ensure_indices_create_index_operation( - &target, - &effect.planned_transaction, - ) - .await? - { - return Err(OmniError::manifest_internal(format!( - "EnsureIndices sidecar '{}' cannot prove its exact first-touch CreateIndex effect for '{}' before deletion", - prepared.operation_id, pin.table_key - ))); - } - dataset - .force_delete_branch(target_branch) - .await - .map_err(OmniError::storage)?; - } - EffectOwnership::None => { - // An untouched owned fork was removed by the helper above. A - // remaining moved ref has no exact owned UUID and is therefore - // a foreign first-touch winner: preserve it, never adopt it. - } - EffectOwnership::OwnBeforeHead | EffectOwnership::Unverifiable => { - return Err(OmniError::manifest_internal(format!( - "EnsureIndices sidecar '{}' cannot safely reclaim first-touch '{}': its exact effect is buried or unverifiable", - prepared.operation_id, pin.table_key - ))); - } - } - } - let mut changes = Vec::new(); let mut expected = HashMap::new(); for ((pin, state), effect) in prepared.tables.iter().zip(states.iter()).map(|pair| { @@ -4985,6 +4832,7 @@ async fn roll_back_ensure_indices_v8( &pin.table_key, &pin.table_path, pin.table_branch.as_deref(), + pin.table_fork_owner.as_deref(), state.manifest_pinned, None, &mut changes, @@ -5537,6 +5385,7 @@ async fn roll_back_schema_apply_v7( restored_table_key, &pin.table_path, None, + None, state.manifest_pinned, None, &mut changes, @@ -5585,6 +5434,7 @@ async fn process_branch_merge_sidecar_v4( if let Some(outcome) = detect_visible_v4_outcome(root_uri, sidecar).await? { return finalize_visible_v4_outcome(root_uri, storage.as_ref(), sidecar, outcome).await; } + validate_table_fork_owner_snapshot(snapshot, sidecar)?; let protocol = sidecar .protocol_v4 .as_ref() @@ -5810,10 +5660,6 @@ async fn process_branch_merge_sidecar_v4( return roll_forward_branch_merge_v4(root_uri, storage, sidecar, mode).await; } - // A first-touch ref with no data commit is a recoverable physical artifact, - // but cleaning it restores the exact pre-attempt graph state. Do not - // manufacture a rollback graph commit: doing so would advance the target - // lineage and turn a later logical fast-forward into a three-way merge. if !any_head_movement { if matches!(mode, RecoveryMode::RollForwardOnly) { warn!( @@ -5822,12 +5668,6 @@ async fn process_branch_merge_sidecar_v4( ); return Ok(false); } - if let NoEffectForkCleanup::DeferredPathChild { .. } = - cleanup_unpublished_no_effect_forks(root_uri, storage.as_ref(), sidecar, &states) - .await? - { - return Ok(false); - } delete_sidecar_by_operation_id(root_uri, storage.as_ref(), &sidecar.operation_id).await?; return Ok(true); } @@ -6173,16 +6013,6 @@ enum EffectOwnership { Unverifiable, } -#[derive(Debug, Clone, PartialEq, Eq)] -enum NoEffectForkCleanup { - Complete, - DeferredPathChild { - table_path: String, - target_branch: String, - path_child: String, - }, -} - fn has_exact_protocol(sidecar: &RecoverySidecar) -> bool { sidecar.protocol_v3.is_some() || sidecar.protocol_v4.is_some() @@ -6194,224 +6024,6 @@ fn has_fixed_rollback_identity(sidecar: &RecoverySidecar) -> bool { has_exact_protocol(sidecar) || sidecar.ensure_indices_rollback_v6.is_some() } -fn v4_effect_for( - sidecar: &RecoverySidecar, - identity: TableIdentity, -) -> Option<&RecoveryBranchMergeEffect> { - sidecar - .protocol_v4 - .as_ref()? - .effects - .iter() - .find(|effect| effect.identity == identity) -} - -fn first_touch_fork_version(sidecar: &RecoverySidecar, pin: &SidecarTablePin) -> u64 { - v4_effect_for(sidecar, pin.identity) - .and_then(|effect| effect.kind.source_fork_version()) - .or_else(|| { - sidecar.protocol_v8.as_ref().and_then(|protocol| { - protocol - .effects - .iter() - .find(|effect| effect.identity == pin.identity) - .and_then(|effect| effect.source_fork_version) - }) - }) - .unwrap_or(pin.expected_version) -} - -/// Remove first-touch named-branch refs created by an Armed exact-protocol -/// attempt, or by the legacy EnsureIndices adapter, that never completed this -/// table's planned effect. -/// -/// The sidecar is durable before the ref is created, so it is the ownership -/// record while the manifest still inherits the table from another branch. -/// Destruction is deliberately narrow: the manifest must not select the ref, -/// no other pending sidecar may claim the same `(table_path, branch)`, and the -/// live ref must still be exactly the fork point. Full recovery is quiesced, so -/// this fresh re-check closes ordinary crash/retry races. Lance does not expose -/// a compare-and-delete-by-branch-identifier primitive; a caller that can -/// reach ref destruction here must either hold the Full-sweep quiescence -/// guarantee or own the intent it destroys — the RFC-022 writer's own -/// conflict-path finalization (`StagedMutation::commit_all`) reclaims the fork -/// it just created under its still-held gate envelope. The live heal -/// (`heal_pending_sidecars_roll_forward`) calls this only through -/// `finalize_effect_free_occ_sidecar` with a fork-free pin set, where no ref -/// surgery is reachable. -async fn cleanup_unpublished_no_effect_forks( - root_uri: &str, - storage: &dyn StorageAdapter, - sidecar: &RecoverySidecar, - states: &[ClassifiedTable], -) -> Result { - if !has_exact_protocol(sidecar) && !matches!(sidecar.writer_kind, SidecarKind::EnsureIndices) { - return Ok(NoEffectForkCleanup::Complete); - } - - // The sidecar's own branch is insufficient authority: another branch may - // lazily borrow this exact table ref. Full recovery is quiesced; an owned - // writer's conflict cleanup holds the schema/branch/table gate envelope. - let fork_references = if states - .iter() - .any(|state| state.unpublished_fork && state.effect_ownership == EffectOwnership::None) - { - Some( - super::ManifestCoordinator::native_fork_references_under_control_gates( - root_uri, - &crate::lance_access::control_session(), - ) - .await?, - ) - } else { - None - }; - let all_sidecars = list_sidecars(root_uri, storage).await?; - for (pin, state) in sidecar.tables.iter().zip(states.iter()) { - if !state.unpublished_fork || state.effect_ownership != EffectOwnership::None { - continue; - } - // Legacy EnsureIndices has no transaction-identity ownership signal. - // Its loose classifier can still prove the no-effect case exactly: - // only `NoMovement` is an untouched fork. A moved HEAD must flow into - // the normal derived-state rollback path; treating it as a no-effect - // ref would try to delete a ref past its fork point and wedge recovery. - if matches!(sidecar.writer_kind, SidecarKind::EnsureIndices) - && !matches!(state.classification, TableClassification::NoMovement) - { - continue; - } - let Some(target_branch) = pin - .table_branch - .as_deref() - .filter(|branch| *branch != "main") - else { - continue; - }; - - if fork_references - .as_ref() - .is_some_and(|references| references.contains(pin.identity, target_branch)) - { - // No effect belongs to this sidecar, so it may retire without - // reclaiming a ref whose history a published snapshot still owns. - continue; - } - - let has_competing_claim = all_sidecars.iter().any(|candidate| { - candidate.operation_id != sidecar.operation_id - && candidate.tables.iter().any(|candidate_pin| { - candidate_pin.table_path == pin.table_path - && candidate_pin.table_branch.as_deref() == Some(target_branch) - }) - }); - if has_competing_claim { - // Never delete a ref while another pending intent claims it. Full - // recovery is quiesced by the shared gates, so this no-effect - // sidecar can safely discard itself without touching the ref. A - // later/last claimant either cleans the still-untouched fork or - // recovers its owned effect. The RollForwardOnly live heal never - // reaches this arm (its fork-free pin set skips the whole loop) - // and continues to defer unresolved fork ownership. - continue; - } - - let mut dataset = crate::instrumentation::open_dataset( - &pin.table_path, - crate::instrumentation::VersionResolution::Latest, - None, - crate::instrumentation::table_wrapper(), - ) - .await?; - let branches = list_branch_contents(&dataset).await?; - 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 { - // 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; - }; - let exact_fork_version = first_touch_fork_version(sidecar, pin); - if contents.parent_version != exact_fork_version { - return Err(OmniError::manifest_internal(format!( - "OCC recovery sidecar '{}' cannot discard unpublished fork '{}:{}': \ - parent version is {}, expected exact fork point {}", - sidecar.operation_id, - pin.table_path, - target_branch, - contents.parent_version, - exact_fork_version - ))); - } - if let Some(expected_identifier) = v4_effect_for(sidecar, pin.identity) - .and_then(|effect| effect.kind.confirmed_branch_identifier()) - .or_else(|| { - sidecar.protocol_v8.as_ref().and_then(|protocol| { - protocol - .effects - .iter() - .find(|effect| effect.identity == pin.identity) - .and_then(|effect| effect.confirmed_branch_identifier.as_ref()) - }) - }) - && &contents.identifier != expected_identifier - { - return Err(OmniError::manifest_internal(format!( - "BranchMerge recovery sidecar '{}' cannot discard unpublished fork '{}:{}': \ - live target ref identity differs from the confirmed effect", - sidecar.operation_id, pin.table_path, target_branch - ))); - } - let target = dataset - .checkout_branch(target_branch) - .await - .map_err(OmniError::storage)?; - if target.version().version != exact_fork_version { - return Err(OmniError::manifest_internal(format!( - "OCC recovery sidecar '{}' cannot discard unpublished fork '{}:{}': \ - live HEAD is {}, expected untouched version {}", - sidecar.operation_id, - pin.table_path, - target_branch, - target.version().version, - exact_fork_version - ))); - } - dataset - .force_delete_branch(target_branch) - .await - .map_err(OmniError::storage)?; - } - Ok(NoEffectForkCleanup::Complete) -} - fn table_requires_rollback_effect(state: &ClassifiedTable) -> bool { matches!( state.classification, @@ -6571,33 +6183,6 @@ 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 - // after fork cleanup and before the first restore so that path can replay it - // without fabricating outcomes from pins. let prepared_fixed = if has_fixed_rollback_identity(sidecar) { Some(prepare_fixed_rollback_audit_plan(root_uri, storage, sidecar, states).await?) } else { @@ -6657,6 +6242,7 @@ async fn roll_back_sidecar( &pin.table_key, &pin.table_path, pin.table_branch.as_deref(), + pin.table_fork_owner.as_deref(), state.manifest_pinned, None, &mut updates, @@ -7703,6 +7289,7 @@ async fn roll_forward_all( &pin.table_key, &pin.table_path, pin.table_branch.as_deref(), + pin.table_fork_owner.as_deref(), pin.expected_version, Some(state.lance_head), &mut updates, @@ -7860,6 +7447,7 @@ async fn push_table_update( table_key: &str, table_path: &str, branch: Option<&str>, + table_fork_owner: Option<&str>, expected_version: u64, target_version: Option, updates: &mut Vec, @@ -7884,7 +7472,8 @@ async fn push_table_update( let row_count = ds.count_rows(None).await.map_err(OmniError::storage)? as u64; let table_relative_path = super::table_path_for_identity(table_key, identity)?; let version_metadata = - super::metadata::TableVersionMetadata::from_dataset(root_uri, &table_relative_path, &ds)?; + super::metadata::TableVersionMetadata::from_dataset(root_uri, &table_relative_path, &ds)? + .with_table_fork_owner(table_fork_owner); updates.push(ManifestChange::Update(DatasetUpdate { identity, type_key: table_key.to_string(), @@ -9158,6 +8747,7 @@ mod tests { fn make_pin(table_key: &str, _table_path: &str, expected: u64, post: u64) -> SidecarTablePin { let identity = test_identity(table_key); SidecarTablePin { + table_fork_owner: None, identity, table_key: table_key.to_string(), table_path: format!( @@ -10182,6 +9772,7 @@ mod tests { ); // Confirmed to the observed HEAD → complete Phase B → roll forward. let confirmed = SidecarTablePin { + table_fork_owner: None, confirmed_version: Some(8), ..make_pin("node:Person", "irrelevant", 5, 6) }; @@ -10661,6 +10252,7 @@ node Person { None, None, vec![SidecarTablePin { + table_fork_owner: None, identity: entry.identity, table_key: "node:Person".to_string(), table_path: table_uri.clone(), @@ -10818,6 +10410,7 @@ node Company { age: I32? } None, vec![ SidecarTablePin { + table_fork_owner: None, identity: person_entry.identity, table_key: "node:Person".to_string(), table_path: person_uri, @@ -10827,6 +10420,7 @@ node Company { age: I32? } table_branch: person_entry.native_dataset_branch, }, SidecarTablePin { + table_fork_owner: None, identity: company_entry.identity, table_key: "node:Company".to_string(), table_path: company_uri, @@ -10979,6 +10573,7 @@ node Person { age: I32? } None, None, vec![SidecarTablePin { + table_fork_owner: None, identity: entry.identity, table_key: "node:Person".to_string(), table_path: table_uri.clone(), diff --git a/crates/omnigraph/src/db/manifest/state.rs b/crates/omnigraph/src/db/manifest/state.rs index 0749922f..68f71bb8 100644 --- a/crates/omnigraph/src/db/manifest/state.rs +++ b/crates/omnigraph/src/db/manifest/state.rs @@ -584,6 +584,16 @@ pub(super) struct PublishScan { pub(super) graph_heads: HashMap, } +pub(super) async fn read_manifest_table_registrations( + dataset: &Dataset, +) -> Result> { + Ok(read_manifest_scan(dataset, false) + .await? + .table_registrations + .into_values() + .collect()) +} + /// One-scan read of everything the publish path needs. `collect_lineage` is /// always on here (the publisher resolves a parent), so the lineage JSON decode /// rides the same pass as the table-state assembly instead of a second scan. @@ -731,6 +741,18 @@ async fn read_manifest_scan_with_clocks( fragments: Option>, use_row_update_versions: bool, ) -> Result { + let historical; + let dataset = if super::migrations::read_stamp(dataset) == Some(7) + && dataset + .schema() + .metadata + .contains_key(super::upgrade::UPGRADE_PENDING_KEY) + { + historical = Box::pin(super::upgrade::historical_source(dataset.clone(), 7)).await?; + &historical + } else { + dataset + }; let legacy = super::migrations::read_stamp(dataset) == Some(6); crate::instrumentation::record_manifest_scan(); // Project only the columns the assembly below reads (RFC-013 PR2 #1c). The diff --git a/crates/omnigraph/src/db/manifest/tests.rs b/crates/omnigraph/src/db/manifest/tests.rs index f053b63a..439eef3c 100644 --- a/crates/omnigraph/src/db/manifest/tests.rs +++ b/crates/omnigraph/src/db/manifest/tests.rs @@ -2976,7 +2976,26 @@ async fn exact_publish_rejects_named_branch_delete_recreate_aba() { // version and exact graph-head absence can repeat; only Lance's native // branch identifier distinguishes the incarnation. mc.delete_branch("feature").await.unwrap(); + assert!( + matches!( + probe_dataset_latest_incarnation(&old_branch, Some("feature")).await, + Err(OmniError::BranchNotFound { .. }) + ), + "a cached native handle must reject retired live authority" + ); + assert_eq!( + old_branch.branch_identifier().await.unwrap(), + old_identifier + ); mc.create_branch("feature").await.unwrap(); + assert_ne!( + probe_dataset_latest_incarnation(&old_branch, Some("feature")) + .await + .unwrap() + .branch_identifier, + old_identifier, + "the freshness probe must resolve the replacement incarnation" + ); let recreated = open_manifest_dataset(uri, Some("feature")).await.unwrap(); let recreated_identifier = recreated.branch_identifier().await.unwrap(); assert_ne!(old_identifier, recreated_identifier); diff --git a/crates/omnigraph/src/db/manifest/upgrade.rs b/crates/omnigraph/src/db/manifest/upgrade.rs index f5f6846c..0257b025 100644 --- a/crates/omnigraph/src/db/manifest/upgrade.rs +++ b/crates/omnigraph/src/db/manifest/upgrade.rs @@ -26,7 +26,9 @@ use super::{ pub(super) const UPGRADE_PENDING_KEY: &str = "omnigraph:storage_upgrade_pending"; const UPGRADE_RECEIPT_KEY: &str = "omnigraph:storage_upgrade_receipt"; const HANDLER: &str = "registration-clocks-v6-to-v7"; -const DEFAULT_TARGET: u32 = 7; +const RETIREMENT_HANDLER: &str = "native-retirement-v7-to-v8"; +const DEFAULT_TARGET: u32 = 8; +const RETIREMENT_KEY: &str = "omnigraph.retired_manifest_branch"; const MAX_BRANCHES: usize = 1024; const MAX_VERSIONS: usize = 100_000; const MAX_APPENDED_UPGRADE_VERSIONS: u64 = 3; @@ -79,6 +81,7 @@ pub struct UpgradeWork { pub payload_bytes_copied: u64, pub payload_bytes_rewritten: u64, pub validation_bytes: Option, + pub deferred_checks: BTreeSet, pub external_blob_exclusions: BTreeSet, pub historical_blob_identity_limits: BTreeSet, } @@ -121,9 +124,10 @@ impl UpgradeReport { self.outcome = UpgradeOutcome::RecoveryRequired; self.finding(code, message); self.recovery = Some(UpgradeRecovery { - failed_handler: HANDLER.into(), - executable_compatibility: "this storage-upgrade-capable v7 executable; do not use the v6 executable".into(), - action: "stop all writers and maintenance, retain the backup, then rerun the same upgrade command with --to-format 7 without --check".into(), + failed_handler: self.route.get(self.completed_handlers.len()).cloned() + .unwrap_or_else(|| "storage-upgrade".into()), + executable_compatibility: "this storage-upgrade-capable v8 executable; preserve the exact pending intent and do not use the source executable".into(), + action: format!("stop all writers and maintenance, retain the backup, then rerun the same upgrade command with --to-format {} without --check", self.target_format), }); } } @@ -219,10 +223,10 @@ fn intent_from(dataset: &Dataset) -> Result> { let intent: UpgradeIntent = serde_json::from_str(json) .map_err(|e| invalid(format!("unrecognized upgrade ownership: {e}")))?; let mut names = HashSet::new(); - if intent.protocol != 1 - || intent.source_format != 6 - || intent.target_format != 7 - || intent.attempt.parse::().is_err() + if !matches!( + (intent.protocol, intent.source_format, intent.target_format), + (1, 6, 7) | (2, 7, 8) + ) || intent.attempt.parse::().is_err() || intent.graph_identity.is_empty() || intent.branches.is_empty() || intent.branches.len() > MAX_BRANCHES @@ -240,6 +244,17 @@ fn intent_from(dataset: &Dataset) -> Result> { Ok(Some(intent)) } +pub(super) fn recovery_guidance(dataset: &Dataset) -> String { + match intent_from(dataset) { + Ok(Some(intent)) => format!( + "storage upgrade recovery required: stop all writers and maintenance and rerun `omnigraph upgrade --to-format {}` with this upgrade-capable v8 executable; preserve the existing attempt.{}", + intent.target_format, + if intent.target_format == 7 { " After completion, run `omnigraph upgrade --to-format 8` before serving with this executable." } else { "" }, + ), + _ => "storage upgrade ownership is unknown: preserve the graph and original upgrade options; use this upgrade-capable v8 executable for read-only `omnigraph upgrade --check` diagnostics before recovery".into(), + } +} + async fn run( root: &str, options: UpgradeOptions, @@ -247,8 +262,89 @@ async fn run( policy: Option<&dyn omnigraph_policy::PolicyChecker>, report: &mut UpgradeReport, ) -> Result<()> { + if !matches!(report.target_format, 7 | 8) { + report.finding( + "unsupported_target", + "this binary has registered routes to formats 7 and 8 only", + ); + return Ok(()); + } + let _root_exclusion = if options.check { + None + } else { + Some(crate::db::reserve_export_root_exclusion(root)?) + }; + for _ in 0..2 { + let main = open(root, None).await?; + if report.observed_format.is_none() { + report.observed_format = read_stamp(&main); + } + let pending = match intent_from(&main) { + Ok(pending) => pending, + Err(error) => { + report.recover("unknown_upgrade_ownership", error.to_string()); + return Ok(()); + } + }; + if pending + .as_ref() + .is_some_and(|intent| intent.target_format > report.target_format) + { + report.recover( + "incompatible_pending_target", + "the pending upgrade cannot be resumed toward a lower target", + ); + if let Some(recovery) = &mut report.recovery { + recovery.action = "stop all writers and maintenance and rerun this executable with --to-format 8; do not alter the pending intent".into(); + } + return Ok(()); + } + let step_target = pending + .as_ref() + .map(|intent| intent.target_format) + .unwrap_or_else(|| { + if read_stamp(&main) == Some(6) { + 7 + } else { + report.target_format + } + }); + let initial_step = pending + .as_ref() + .map(|intent| intent.source_format) + .unwrap_or_else(|| read_stamp(&main).unwrap_or(0)); + if initial_step == 6 && report.target_format == 8 { + report.route = vec![HANDLER.into(), RETIREMENT_HANDLER.into()]; + } else if initial_step == 7 && report.target_format == 8 && report.route.is_empty() { + report.route.push(RETIREMENT_HANDLER.into()); + } + run_step(root, options, actor, policy, report, step_target).await?; + if options.check { + if step_target < report.target_format && report.success() { + report.work.deferred_checks.insert("v7-to-v8 preflight must validate the converted v7 output before the second handler has effects".into()); + } + return Ok(()); + } + if !report.success() || step_target == report.target_format { + return Ok(()); + } + report.work.deferred_checks.clear(); + } + Err(invalid( + "storage upgrade route exceeded its two registered handlers", + )) +} + +async fn run_step( + root: &str, + options: UpgradeOptions, + actor: Option<&str>, + policy: Option<&dyn omnigraph_policy::PolicyChecker>, + report: &mut UpgradeReport, + step_target: u32, +) -> Result<()> { + report.outcome = UpgradeOutcome::CheckFailed; let main = open(root, None).await?; - report.observed_format = read_stamp(&main); let pending = match intent_from(&main) { Ok(value) => value, Err(error) => { @@ -262,10 +358,6 @@ async fn run( "an owned storage conversion requires explicit recovery", ); } - if report.target_format != DEFAULT_TARGET { - report.finding("unsupported_target", "this binary declares target format 7 only; no complete route exists to the requested target"); - return Ok(()); - } let storage = storage_for_uri(root)?; let (_, schema_state) = crate::db::schema_state::load_validated_schema_contract(root, Arc::clone(&storage)).await?; @@ -283,14 +375,26 @@ async fn run( }); return Ok(()); } - if pending.is_none() && report.observed_format == Some(DEFAULT_TARGET) { - super::migrations::guard_stamp(&main)?; + if pending.is_none() && read_stamp(&main) == Some(step_target) { + if step_target == 8 { + super::migrations::guard_stamp(&main)?; + } read_manifest_state(&main).await?; - let branches = crate::branch_control::list_branch_contents(&main).await?; + let branches = if step_target == 8 { + crate::branch_control::list_live_manifest_branch_contents(&main).await? + } else { + legacy_branch_contents(&main).await? + }; if branches.len() >= MAX_BRANCHES { return Err(invalid("storage upgrade branch limit exceeded")); } + let mut logical_names = HashSet::from(["main".to_string()]); for native in branches.keys() { + if !logical_names.insert(crate::branch_names::logical_branch_name(native).to_string()) { + return Err(invalid( + "current graph contains duplicate live logical branch names", + )); + } if crate::db::is_internal_system_branch(native) { return Err(invalid( "resolve internal branch recovery before storage upgrade", @@ -300,17 +404,31 @@ async fn run( .checkout_branch(native) .await .map_err(OmniError::storage)?; - super::migrations::guard_stamp(&branch)?; + if read_stamp(&branch) != Some(step_target) + || branch.schema().metadata.contains_key(UPGRADE_PENDING_KEY) + { + return Err(invalid( + "current graph contains a branch with an incompatible format or pending upgrade", + )); + } read_manifest_state(&branch).await?; } - report.outcome = UpgradeOutcome::AlreadyCurrent; + report.outcome = if report.completed_handlers.is_empty() { + UpgradeOutcome::AlreadyCurrent + } else { + UpgradeOutcome::Completed + }; return Ok(()); } - if pending.is_none() && report.observed_format != Some(6) { - report.finding("unsupported_source", "only validated v6 graphs have a conversion handler; preserve the source and use its executable for export/import"); + if pending.is_none() + && !matches!( + (read_stamp(&main), step_target), + (Some(6), 7) | (Some(7), 8) + ) + { + report.finding("unsupported_source", "only validated v6 and v7 graphs have conversion handlers; preserve the source and use its executable for export/import"); return Ok(()); } - report.route.push(HANDLER.into()); let intent = match pending { Some(intent) => { if intent.graph_identity != schema_state.schema_identity_domain { @@ -320,6 +438,14 @@ async fn run( } None => inventory(&main, schema_state.schema_identity_domain.clone()).await?, }; + let handler = if intent.source_format == 6 { + HANDLER + } else { + RETIREMENT_HANDLER + }; + if !report.route.iter().any(|entry| entry == handler) { + report.route.push(handler.into()); + } for branch in &intent.branches { if let Some(checker) = policy { let actor = actor.ok_or_else(|| { @@ -349,7 +475,6 @@ async fn run( } return Ok(()); } - let _root_exclusion = crate::db::reserve_export_root_exclusion(root)?; verify_inventory(root, &intent, report.recovery.is_some()).await?; if report.recovery.is_none() { let main = open(root, None).await?; @@ -363,7 +488,7 @@ async fn run( "fence_publication_attempted", "inspect durable ownership before retrying an uncertain fence publication", ); - publish_fence(main, json).await?; + publish_fence(main, json, intent.target_format).await?; } report.last_durable_completed_boundary = Some("source_fenced".into()); crate::failpoints::maybe_fail(crate::failpoints::names::UPGRADE_AFTER_FENCE)?; @@ -380,7 +505,7 @@ async fn run( if branch_completed(¤t, branch, &intent)? { continue; } - verify_source_head(¤t, branch, branch.native.is_none())?; + verify_source_head(¤t, branch, branch.native.is_none(), &intent)?; let source = current .checkout_version(branch.version) .await @@ -413,14 +538,29 @@ async fn run( report.last_durable_completed_boundary = Some("activated".into()); crate::failpoints::maybe_fail(crate::failpoints::names::UPGRADE_AFTER_ACTIVATION)?; report.outcome = UpgradeOutcome::Completed; - report.completed_handlers.push(HANDLER.into()); + report.completed_handlers.push(handler.into()); report.recovery = None; report.findings.clear(); Ok(()) } -async fn inventory(main: &Dataset, graph_identity: String) -> Result { +async fn legacy_branch_contents( + main: &Dataset, +) -> Result> { let branches = crate::branch_control::list_branch_contents(main).await?; + if branches + .values() + .any(|contents| contents.metadata.contains_key(RETIREMENT_KEY)) + { + return Err(invalid( + "v6/v7 source contains reserved retirement metadata; ownership cannot be inferred", + )); + } + Ok(branches) +} + +async fn inventory(main: &Dataset, graph_identity: String) -> Result { + let branches = legacy_branch_contents(main).await?; if branches.len() >= MAX_BRANCHES { return Err(invalid("storage upgrade branch limit exceeded")); } @@ -448,11 +588,12 @@ async fn inventory(main: &Dataset, graph_identity: String) -> Result Result BranchReceipt { BranchReceipt { - protocol: 1, + protocol: intent.protocol, attempt: intent.attempt.clone(), source: branch.clone(), } @@ -481,9 +622,26 @@ fn branch_completed( } let found: BranchReceipt = serde_json::from_str(raw).map_err(|e| invalid(format!("invalid upgrade receipt: {e}")))?; - if found != receipt(source, intent) || read_stamp(dataset) != Some(7) { + if found != receipt(source, intent) { + let source_head = source + .version + .checked_add(u64::from( + source.native.is_none() + && dataset.schema().metadata.contains_key(UPGRADE_PENDING_KEY), + )) + .ok_or_else(|| invalid("upgrade version overflow"))?; + if intent.protocol == 2 + && found.protocol == 1 + && found.attempt != intent.attempt + && dataset.version().version == source_head + { + return Ok(false); + } return Err(invalid("foreign upgrade receipt")); } + if read_stamp(dataset) != Some(intent.target_format) { + return Err(invalid("upgrade receipt has an incompatible format")); + } let expected = source .version .checked_add(if source.native.is_none() { 2 } else { 1 }) @@ -501,7 +659,12 @@ fn branch_completed( Ok(true) } -fn verify_source_head(dataset: &Dataset, source: &SourceBranch, fenced: bool) -> Result<()> { +fn verify_source_head( + dataset: &Dataset, + source: &SourceBranch, + fenced: bool, + intent: &UpgradeIntent, +) -> Result<()> { let expected = source .version .checked_add(u64::from(fenced)) @@ -509,18 +672,21 @@ fn verify_source_head(dataset: &Dataset, source: &SourceBranch, fenced: bool) -> if dataset.version().version != expected { return Err(invalid("source branch changed; refusing foreign movement")); } - if !fenced && read_stamp(dataset) != Some(6) { - return Err(invalid("source branch is not v6")); + if read_stamp(dataset) + != Some(if fenced { + intent.target_format + } else { + intent.source_format + }) + { + return Err(invalid("source branch has an incompatible format")); } Ok(()) } async fn verify_inventory(root: &str, intent: &UpgradeIntent, fenced: bool) -> Result<()> { let main = open(root, None).await?; - let observed: BTreeSet<_> = crate::branch_control::list_branch_contents(&main) - .await? - .into_keys() - .collect(); + let observed: BTreeSet<_> = legacy_branch_contents(&main).await?.into_keys().collect(); let expected: BTreeSet<_> = intent .branches .iter() @@ -543,7 +709,7 @@ async fn verify_inventory(root: &str, intent: &UpgradeIntent, fenced: bool) -> R return Err(invalid("native branch lifetime changed during upgrade")); } if !branch_completed(&dataset, source, intent)? { - verify_source_head(&dataset, source, fenced && source.native.is_none())?; + verify_source_head(&dataset, source, fenced && source.native.is_none(), intent)?; } } Ok(()) @@ -596,7 +762,7 @@ async fn preflight(root: &str, intent: &UpgradeIntent, work: &mut UpgradeWork) - .checkout_version(branch.version) .await .map_err(OmniError::storage)?; - if read_stamp(&source) != Some(6) { + if read_stamp(&source) != Some(intent.source_format) { return Err(invalid("source history has an unsupported format")); } if source @@ -612,9 +778,18 @@ async fn preflight(root: &str, intent: &UpgradeIntent, work: &mut UpgradeWork) - } validate_metadata_budget(&source).await?; let (old, lineage) = super::state::read_manifest_state_and_lineage(&source).await?; - validate_source_branch_identity(branch, &old, &lineage)?; - let mut translated = read_manifest_state_with_registration_clocks(&source).await?; - compare_states(old, &mut translated)?; + if intent.source_format == 6 { + validate_source_branch_identity(branch, &old, &lineage)?; + } else if branch.native.is_some() + && (branch.identity == BranchIdentifier::main() + || branch.identity == BranchIdentifier::missing_identifier_sentinel()) + { + return Err(invalid("v7 native branch lacks an exact lifetime identity")); + } + if intent.source_format == 6 { + let mut translated = read_manifest_state_with_registration_clocks(&source).await?; + compare_states(old, &mut translated)?; + } let schema: Schema = source.schema().into(); let expected = super::state::manifest_schema(); if schema.fields().len() != expected.fields().len() @@ -646,12 +821,14 @@ async fn preflight(root: &str, intent: &UpgradeIntent, work: &mut UpgradeWork) - let mut seen = HashSet::new(); let mut count = 0usize; while let Some(batch) = stream.try_next().await.map_err(OmniError::storage)? { - convert_batch( - batch.clone(), - Arc::clone(&schema), - branch.version, - &mut seen, - )?; + if intent.source_format == 6 { + convert_batch( + batch.clone(), + Arc::clone(&schema), + branch.version, + &mut seen, + )?; + } count = count .checked_add(batch.num_rows()) .ok_or_else(|| invalid("metadata row count overflow"))?; @@ -666,12 +843,16 @@ async fn preflight(root: &str, intent: &UpgradeIntent, work: &mut UpgradeWork) - .checkout_version(version.version) .await .map_err(OmniError::storage)?; + let snapshot = historical_source(snapshot, intent.source_format).await?; let unstamped_bootstrap = matches!(snapshot.version().version, 1 | 2) && !snapshot .schema() .metadata .contains_key(INTERNAL_SCHEMA_VERSION_KEY); - if read_stamp(&snapshot) != Some(6) && !unstamped_bootstrap { + if !matches!(read_stamp(&snapshot), Some(6)) + && !(intent.source_format == 7 && read_stamp(&snapshot) == Some(7)) + && !unstamped_bootstrap + { return Err(invalid(format!( "retained history contains an unsupported format at {:?} version {}", branch.native, @@ -815,6 +996,63 @@ fn validate_source_branch_identity( Ok(()) } +pub(super) async fn historical_source(snapshot: Dataset, source_format: u32) -> Result { + if source_format != 7 || !snapshot.schema().metadata.contains_key(UPGRADE_PENDING_KEY) { + return Ok(snapshot); + } + let intent = + intent_from(&snapshot)?.ok_or_else(|| invalid("historical upgrade intent disappeared"))?; + if intent.protocol != 1 || snapshot.manifest().branch.is_some() { + return Err(invalid("unsupported historical upgrade ownership")); + } + let main = intent + .branches + .last() + .ok_or_else(|| invalid("historical upgrade has no main source"))?; + if branch_completed(&snapshot, main, &intent)? { + return Ok(snapshot); + } + let expected = main + .version + .checked_add(1) + .ok_or_else(|| invalid("upgrade version overflow"))?; + let transaction = snapshot + .read_transaction() + .await + .map_err(OmniError::storage)? + .ok_or_else(|| invalid("historical upgrade fence has no transaction proof"))?; + let json = snapshot + .schema() + .metadata + .get(UPGRADE_PENDING_KEY) + .ok_or_else(|| invalid("historical upgrade intent disappeared"))? + .clone(); + let operation = Transaction::new(main.version, fence_operation(json, 7), None); + if snapshot.version().version != expected + || read_stamp(&snapshot) != Some(7) + || transaction.read_version != main.version + || lance_table::format::pb::Transaction::from(&transaction).operation + != lance_table::format::pb::Transaction::from(&operation).operation + || snapshot + .branch_identifier() + .await + .map_err(OmniError::storage)? + != main.identity + { + return Err(invalid( + "historical upgrade fence does not match its exact source", + )); + } + let source = snapshot + .checkout_version(main.version) + .await + .map_err(OmniError::storage)?; + if read_stamp(&source) != Some(6) { + return Err(invalid("historical upgrade fence source is not v6")); + } + Ok(source) +} + async fn retained_version_refs( dataset: &Dataset, limit: usize, @@ -864,20 +1102,27 @@ async fn equivalent(source: &Dataset, target: &Dataset) -> Result<()> { compare_states(old, &mut converted) } -async fn publish_fence(dataset: Dataset, intent: String) -> Result { - let operation = Operation::UpdateConfig { +fn fence_operation(intent: String, target: u32) -> Operation { + Operation::UpdateConfig { config_updates: None, table_metadata_updates: None, field_metadata_updates: HashMap::new(), schema_metadata_updates: Some(UpdateMap { update_entries: vec![ - (INTERNAL_SCHEMA_VERSION_KEY.to_string(), "7".to_string()).into(), + (INTERNAL_SCHEMA_VERSION_KEY.to_string(), target.to_string()).into(), (UPGRADE_PENDING_KEY.to_string(), intent).into(), ], replace: false, }), - }; - let transaction = Transaction::new(dataset.version().version, operation, None); + } +} + +async fn publish_fence(dataset: Dataset, intent: String, target: u32) -> Result { + let transaction = Transaction::new( + dataset.version().version, + fence_operation(intent, target), + None, + ); CommitBuilder::new(Arc::new(dataset)) .with_max_retries(0) .with_skip_auto_cleanup(true) @@ -912,7 +1157,10 @@ async fn publish_conversion( intent: &UpgradeIntent, ) -> Result<()> { let mut metadata = source.schema().metadata.clone(); - metadata.insert(INTERNAL_SCHEMA_VERSION_KEY.into(), "7".into()); + metadata.insert( + INTERNAL_SCHEMA_VERSION_KEY.into(), + intent.target_format.to_string(), + ); metadata.remove(UPGRADE_PENDING_KEY); if branch.native.is_none() { metadata.insert( @@ -924,45 +1172,61 @@ async fn publish_conversion( UPGRADE_RECEIPT_KEY.into(), serde_json::to_string(&receipt(branch, intent)).map_err(|e| invalid(e.to_string()))?, ); - let schema: Schema = source.schema().into(); - let schema = Arc::new(schema.with_metadata(metadata)); - let mut scan = source.scan(); - let mut columns: Vec = schema - .fields() - .iter() - .map(|field| field.name().clone()) - .collect(); - columns.push("_row_last_updated_at_version".into()); - scan.project(&columns).map_err(OmniError::storage)?; - scan.batch_size(1024); - let batches = scan.try_into_stream().await.map_err(OmniError::storage)?; - let output_schema = Arc::clone(&schema); - let mut seen = HashSet::new(); - let version = source.version().version; - let converted = batches - .map_err(datafusion::error::DataFusionError::from) - .and_then(move |batch| { - let result = convert_batch(batch, Arc::clone(&output_schema), version, &mut seen) - .map_err(|error| datafusion::error::DataFusionError::External(Box::new(error))); - futures::future::ready(result) - }); - let stream: datafusion::physical_plan::SendableRecordBatchStream = - Box::pin(RecordBatchStreamAdapter::new(schema, converted)); - let params = WriteParams { - mode: WriteMode::Overwrite, - enable_stable_row_ids: true, - data_storage_version: Some(LanceFileVersion::V2_2), - skip_auto_cleanup: true, - max_rows_per_file: 64 * 1024, - max_rows_per_group: 1024, - ..Default::default() - }; let destination = Arc::new(current); - let transaction = InsertBuilder::new(Arc::clone(&destination)) - .with_params(¶ms) - .execute_uncommitted_stream(stream) - .await - .map_err(OmniError::storage)?; + let transaction = if intent.source_format == 7 { + let operation = Operation::UpdateConfig { + config_updates: None, + table_metadata_updates: None, + field_metadata_updates: HashMap::new(), + schema_metadata_updates: Some(UpdateMap { + update_entries: metadata + .into_iter() + .map(|(key, value)| (key, value).into()) + .collect(), + replace: true, + }), + }; + Transaction::new(destination.version().version, operation, None) + } else { + let schema: Schema = source.schema().into(); + let schema = Arc::new(schema.with_metadata(metadata)); + let mut scan = source.scan(); + let mut columns: Vec = schema + .fields() + .iter() + .map(|field| field.name().clone()) + .collect(); + columns.push("_row_last_updated_at_version".into()); + scan.project(&columns).map_err(OmniError::storage)?; + scan.batch_size(1024); + let batches = scan.try_into_stream().await.map_err(OmniError::storage)?; + let output_schema = Arc::clone(&schema); + let mut seen = HashSet::new(); + let version = source.version().version; + let converted = batches + .map_err(datafusion::error::DataFusionError::from) + .and_then(move |batch| { + let result = convert_batch(batch, Arc::clone(&output_schema), version, &mut seen) + .map_err(|error| datafusion::error::DataFusionError::External(Box::new(error))); + futures::future::ready(result) + }); + let stream: datafusion::physical_plan::SendableRecordBatchStream = + Box::pin(RecordBatchStreamAdapter::new(schema, converted)); + let params = WriteParams { + mode: WriteMode::Overwrite, + enable_stable_row_ids: true, + data_storage_version: Some(LanceFileVersion::V2_2), + skip_auto_cleanup: true, + max_rows_per_file: 64 * 1024, + max_rows_per_group: 1024, + ..Default::default() + }; + InsertBuilder::new(Arc::clone(&destination)) + .with_params(¶ms) + .execute_uncommitted_stream(stream) + .await + .map_err(OmniError::storage)? + }; crate::failpoints::maybe_fail(crate::failpoints::names::UPGRADE_AFTER_STAGE)?; let target = CommitBuilder::new(destination) .with_max_retries(0) diff --git a/crates/omnigraph/src/db/manifest/upgrade/tests.rs b/crates/omnigraph/src/db/manifest/upgrade/tests.rs index 0b5d45df..f8a16359 100644 --- a/crates/omnigraph/src/db/manifest/upgrade/tests.rs +++ b/crates/omnigraph/src/db/manifest/upgrade/tests.rs @@ -36,8 +36,8 @@ async fn synthetic_v6_fixture(root: &str) { lance::dataset::cleanup::cleanup_old_versions(&dataset, policy) .await .unwrap(); - dataset - .create_branch("feature", dataset.version().version, None) + let version = dataset.version().version; + crate::storage_layer::lance_clone::create_branch(&mut dataset, "feature", version) .await .unwrap(); } @@ -83,7 +83,7 @@ async fn storage_upgrade_check_has_no_local_store_effects() { root, UpgradeOptions { check: true, - to_format: Some(7), + to_format: Some(8), }, ), ) @@ -100,7 +100,8 @@ async fn storage_upgrade_check_has_no_local_store_effects() { ); assert_eq!(report.outcome, UpgradeOutcome::CheckPassed, "{report:?}"); assert_eq!(stored_files(dir.path()), before); - assert!(!report.route.is_empty()); + assert_eq!(report.route, [HANDLER, RETIREMENT_HANDLER]); + assert!(!report.work.deferred_checks.is_empty()); assert!(report.work.retained_snapshots >= 2); assert!(Omnigraph::open(root).await.is_err()); assert!(Omnigraph::open_read_only(root).await.is_err()); @@ -108,7 +109,7 @@ async fn storage_upgrade_check_has_no_local_store_effects() { root, UpgradeOptions { check: false, - to_format: Some(8), + to_format: Some(9), }, ) .await @@ -122,80 +123,97 @@ async fn storage_upgrade_check_has_no_local_store_effects() { async fn storage_upgrade_interruption_boundaries_retry_without_mixed_visibility() { use crate::failpoints::{FailScenario, ScopedFailPoint, names}; let _scenario = FailScenario::setup(); - for boundary in [ - names::UPGRADE_AFTER_FENCE, - names::UPGRADE_AFTER_STAGE, - names::UPGRADE_AFTER_BRANCH, - names::UPGRADE_BEFORE_ACTIVATION, - names::UPGRADE_AFTER_ACTIVATION, - ] { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().to_str().unwrap(); - synthetic_v6_fixture(root).await; - let report = { - let _fault = ScopedFailPoint::new(boundary, "return"); - upgrade_storage(root, UpgradeOptions::default()) + for source_format in [6, 7] { + for boundary in [ + names::UPGRADE_AFTER_FENCE, + names::UPGRADE_AFTER_STAGE, + names::UPGRADE_AFTER_BRANCH, + names::UPGRADE_BEFORE_ACTIVATION, + names::UPGRADE_AFTER_ACTIVATION, + ] { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_str().unwrap(); + synthetic_v6_fixture(root).await; + if source_format == 7 { + let first = upgrade_storage( + root, + UpgradeOptions { + check: false, + to_format: Some(7), + }, + ) .await - .unwrap() - }; - assert_eq!( - report.outcome, - UpgradeOutcome::RecoveryRequired, - "{boundary}: {report:?}" - ); - let activated = boundary == names::UPGRADE_AFTER_ACTIVATION; - assert_eq!(Omnigraph::open(root).await.is_ok(), activated, "{boundary}"); - assert_eq!( - Omnigraph::open_read_only(root).await.is_ok(), - activated, - "{boundary}" - ); - let before_check = stored_files(dir.path()); - let check = upgrade_storage( - root, - UpgradeOptions { - check: true, - to_format: Some(7), - }, - ) - .await - .unwrap(); - assert_eq!( - check.outcome, - if activated { - UpgradeOutcome::AlreadyCurrent - } else { - UpgradeOutcome::RecoveryRequired - }, - "{boundary}: {check:?}" - ); - assert_eq!(stored_files(dir.path()), before_check, "{boundary}"); - let retried = upgrade_storage(root, UpgradeOptions::default()) + .unwrap(); + assert_eq!(first.outcome, UpgradeOutcome::Completed, "{first:?}"); + } + let report = { + let _fault = ScopedFailPoint::new(boundary, "return"); + upgrade_storage(root, UpgradeOptions::default()) + .await + .unwrap() + }; + assert_eq!( + report.outcome, + UpgradeOutcome::RecoveryRequired, + "{boundary}: {report:?}" + ); + let activated = boundary == names::UPGRADE_AFTER_ACTIVATION && source_format == 7; + let intermediate = boundary == names::UPGRADE_AFTER_ACTIVATION && source_format == 6; + assert_eq!(Omnigraph::open(root).await.is_ok(), activated, "{boundary}"); + assert_eq!( + Omnigraph::open_read_only(root).await.is_ok(), + activated, + "{boundary}" + ); + let before_check = stored_files(dir.path()); + let check = upgrade_storage( + root, + UpgradeOptions { + check: true, + to_format: Some(8), + }, + ) .await .unwrap(); - assert_eq!( - retried.outcome, - if activated { - UpgradeOutcome::AlreadyCurrent - } else { - UpgradeOutcome::Completed - }, - "{boundary}: {retried:?}" - ); - assert!(Omnigraph::open(root).await.is_ok(), "{boundary}"); - assert!(Omnigraph::open_read_only(root).await.is_ok(), "{boundary}"); - for branch in [None, Some("feature")] { - let dataset = open(root, branch).await.unwrap(); - assert_eq!(read_stamp(&dataset), Some(7), "{boundary}"); + assert_eq!( + check.outcome, + if activated { + UpgradeOutcome::AlreadyCurrent + } else if intermediate { + UpgradeOutcome::CheckPassed + } else { + UpgradeOutcome::RecoveryRequired + }, + "{boundary}: {check:?}" + ); + assert_eq!(stored_files(dir.path()), before_check, "{boundary}"); + let retried = upgrade_storage(root, UpgradeOptions::default()) + .await + .unwrap(); + assert_eq!( + retried.outcome, + if activated { + UpgradeOutcome::AlreadyCurrent + } else { + UpgradeOutcome::Completed + }, + "{boundary}: {retried:?}" + ); + assert!(Omnigraph::open(root).await.is_ok(), "{boundary}"); + assert!(Omnigraph::open_read_only(root).await.is_ok(), "{boundary}"); + for branch in [None, Some("feature")] { + let dataset = open(root, branch).await.unwrap(); + assert_eq!(read_stamp(&dataset), Some(8), "{boundary}"); + } + let repeated = upgrade_storage(root, UpgradeOptions::default()) + .await + .unwrap(); + assert_eq!( + repeated.outcome, + UpgradeOutcome::AlreadyCurrent, + "{boundary}" + ); } - let repeated = upgrade_storage(root, UpgradeOptions::default()) - .await - .unwrap(); - assert_eq!( - repeated.outcome, - UpgradeOutcome::AlreadyCurrent, - "{boundary}" - ); } } @@ -427,6 +445,7 @@ async fn storage_upgrade_refuses_preexisting_recovery_without_healing() { post_commit_pin: entry.published_dataset_version + 1, confirmed_version: None, table_branch: entry.native_dataset_branch, + table_fork_owner: None, }; let sidecar = super::super::recovery::new_optimize_sidecar_v9(vec![pin]).unwrap(); let recovery = dir.path().join("__recovery"); @@ -477,7 +496,7 @@ async fn storage_upgrade_current_main_refuses_legacy_branch_without_effects() { let root = dir.path().to_str().unwrap(); synthetic_v6_fixture(root).await; let mut main = open(root, None).await.unwrap(); - main.update_schema_metadata([(INTERNAL_SCHEMA_VERSION_KEY, "7")]) + main.update_schema_metadata([(INTERNAL_SCHEMA_VERSION_KEY, "8")]) .await .unwrap(); assert!(!main.schema().metadata.contains_key(UPGRADE_PENDING_KEY)); @@ -491,7 +510,7 @@ async fn storage_upgrade_current_main_refuses_legacy_branch_without_effects() { root, UpgradeOptions { check, - to_format: Some(7), + to_format: Some(8), }, ) .await @@ -546,3 +565,396 @@ async fn storage_upgrade_history_budget_precedes_manifest_reads() { "retry excludes the protocol's appended versions" ); } + +#[tokio::test] +async fn storage_upgrade_v7_to_v8_preserves_manifest_fragments_and_history() { + #[cfg(feature = "failpoints")] + let _scenario = crate::failpoints::FailScenario::setup(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_str().unwrap(); + synthetic_v6_fixture(root).await; + let first = upgrade_storage( + root, + UpgradeOptions { + check: false, + to_format: Some(7), + }, + ) + .await + .unwrap(); + assert_eq!(first.outcome, UpgradeOutcome::Completed, "{first:?}"); + assert_eq!(first.completed_handlers, [HANDLER]); + assert!(Omnigraph::open(root).await.is_err()); + let empty_native = + crate::branch_names::native_branch_name("empty", &crate::branch_names::mint_incarnation()); + let mut main = open(root, None).await.unwrap(); + let version = main.version().version; + crate::storage_layer::lance_clone::create_branch(&mut main, &empty_native, version) + .await + .unwrap(); + let mut sources = Vec::new(); + for native in [None, Some("feature"), Some(empty_native.as_str())] { + let dataset = open(root, native).await.unwrap(); + let tag = format!("before-{}", native.unwrap_or("main")); + dataset + .tags() + .create( + &tag, + lance::dataset::refs::Ref::from((native, Some(dataset.version().version))), + ) + .await + .unwrap(); + sources.push((native, dataset)); + } + let before_tags = serde_json::to_value(main.tags().list().await.unwrap()).unwrap(); + let before = stored_files(dir.path()); + for target in [Some(7), Some(8)] { + let checked = upgrade_storage( + root, + UpgradeOptions { + check: true, + to_format: target, + }, + ) + .await + .unwrap(); + assert_eq!( + checked.outcome, + if target == Some(7) { + UpgradeOutcome::AlreadyCurrent + } else { + UpgradeOutcome::CheckPassed + }, + "{checked:?}" + ); + assert_eq!(stored_files(dir.path()), before); + } + let result = upgrade_storage(root, UpgradeOptions::default()) + .await + .unwrap(); + assert_eq!(result.outcome, UpgradeOutcome::Completed, "{result:?}"); + assert_eq!(result.route, [RETIREMENT_HANDLER]); + for (native, source) in sources { + let target = open(root, native).await.unwrap(); + assert_eq!(read_stamp(&target), Some(8)); + assert_eq!(target.manifest().fragments, source.manifest().fragments); + assert_eq!(target.manifest().base_paths, source.manifest().base_paths); + assert_eq!( + target.branch_identifier().await.unwrap(), + source.branch_identifier().await.unwrap() + ); + let historical = target + .checkout_version(source.version().version) + .await + .unwrap(); + assert_eq!(read_stamp(&historical), Some(7)); + equivalent(&historical, &target).await.unwrap(); + assert_eq!( + serde_json::to_value(target.tags().list().await.unwrap()).unwrap(), + before_tags + ); + } + assert!(Omnigraph::open(root).await.is_ok()); + let before = stored_files(dir.path()); + let downgrade = upgrade_storage( + root, + UpgradeOptions { + check: false, + to_format: Some(7), + }, + ) + .await + .unwrap(); + assert_eq!( + downgrade.outcome, + UpgradeOutcome::CheckFailed, + "{downgrade:?}" + ); + assert_eq!(stored_files(dir.path()), before); +} + +#[cfg(feature = "failpoints")] +#[tokio::test] +async fn storage_upgrade_preserves_prior_v6_to_v7_pending_intent_before_continuing() { + use crate::failpoints::{FailScenario, ScopedFailPoint, names}; + let _scenario = FailScenario::setup(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_str().unwrap(); + synthetic_v6_fixture(root).await; + { + let _fault = ScopedFailPoint::new(names::UPGRADE_AFTER_FENCE, "return"); + let report = upgrade_storage( + root, + UpgradeOptions { + check: false, + to_format: Some(7), + }, + ) + .await + .unwrap(); + assert_eq!( + report.outcome, + UpgradeOutcome::RecoveryRequired, + "{report:?}" + ); + } + let pending = intent_from(&open(root, None).await.unwrap()) + .unwrap() + .unwrap(); + assert_eq!( + ( + pending.protocol, + pending.source_format, + pending.target_format + ), + (1, 6, 7) + ); + let before = stored_files(dir.path()); + let checked = upgrade_storage( + root, + UpgradeOptions { + check: true, + to_format: Some(8), + }, + ) + .await + .unwrap(); + assert_eq!( + checked.outcome, + UpgradeOutcome::RecoveryRequired, + "{checked:?}" + ); + assert_eq!( + intent_from(&open(root, None).await.unwrap()).unwrap(), + Some(pending.clone()) + ); + assert_eq!(stored_files(dir.path()), before); + let result = upgrade_storage(root, UpgradeOptions::default()) + .await + .unwrap(); + assert_eq!(result.outcome, UpgradeOutcome::Completed, "{result:?}"); + assert_eq!(result.completed_handlers, [HANDLER, RETIREMENT_HANDLER]); + for branch in &pending.branches { + let current = open(root, branch.native.as_deref()).await.unwrap(); + let old_completion = current + .checkout_version(branch.version + if branch.native.is_none() { 3 } else { 1 }) + .await + .unwrap(); + assert_eq!(read_stamp(&old_completion), Some(7)); + let old_receipt: BranchReceipt = serde_json::from_str( + old_completion + .schema() + .metadata + .get(UPGRADE_RECEIPT_KEY) + .unwrap(), + ) + .unwrap(); + assert_eq!(old_receipt, receipt(branch, &pending)); + assert_eq!(read_stamp(¤t), Some(8)); + } + let source_version = pending.branches.last().unwrap().version; + for _ in 0..2 { + let reopened = Omnigraph::open(root).await.unwrap(); + let before_fence = reopened + .snapshot_at_graph_manifest_version(source_version) + .await + .unwrap(); + let fence = reopened + .snapshot_at_graph_manifest_version(source_version + 1) + .await + .unwrap(); + assert_eq!(fence.version, source_version + 1); + assert_eq!(fence.graph_heads, before_fence.graph_heads); + assert_eq!(fence.entries.len(), before_fence.entries.len()); + for (key, entry) in &before_fence.entries { + assert!(fence.entries[key].same_registration(entry)); + } + drop(reopened); + } + { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_str().unwrap(); + drop( + Omnigraph::init(root, "node Person { name: String }") + .await + .unwrap(), + ); + let mut dataset = open(root, None).await.unwrap(); + dataset + .update_schema_metadata([(INTERNAL_SCHEMA_VERSION_KEY, "6")]) + .await + .unwrap(); + let source_version = dataset.version().version; + let intent = inventory(&dataset, "historical-proof-fixture".into()) + .await + .unwrap(); + dataset + .update_schema_metadata([ + (INTERNAL_SCHEMA_VERSION_KEY.to_string(), "7".to_string()), + ( + UPGRADE_PENDING_KEY.to_string(), + serde_json::to_string(&intent).unwrap(), + ), + ( + "test:foreign-fence-effect".to_string(), + "unowned".to_string(), + ), + ]) + .await + .unwrap(); + let forged_version = dataset.version().version; + assert_eq!(forged_version, source_version + 1); + let mut dataset = publish_activation(dataset).await.unwrap(); + dataset + .update_schema_metadata([(INTERNAL_SCHEMA_VERSION_KEY, "8")]) + .await + .unwrap(); + drop(dataset); + for _ in 0..2 { + let db = Omnigraph::open(root).await.unwrap(); + assert!( + db.snapshot_at_graph_manifest_version(source_version) + .await + .is_ok() + ); + let error = db + .snapshot_at_graph_manifest_version(forged_version) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("does not match its exact source"), + "{error}" + ); + drop(db); + } + } +} + +#[tokio::test] +async fn storage_upgrade_current_v8_preserves_retired_ancestry_and_recreated_name() { + #[cfg(feature = "failpoints")] + let _scenario = crate::failpoints::FailScenario::setup(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_str().unwrap(); + let db = Omnigraph::init(root, "node Person { name: String }") + .await + .unwrap(); + db.branch_create("parent").await.unwrap(); + db.branch_create_from(crate::db::ReadTarget::branch("parent"), "child") + .await + .unwrap(); + db.branch_delete("parent").await.unwrap(); + db.branch_create("parent").await.unwrap(); + drop(db); + let main = open(root, None).await.unwrap(); + let physical = crate::branch_control::list_branch_contents(&main) + .await + .unwrap(); + assert_eq!(physical.len(), 3); + assert_eq!( + crate::branch_control::list_live_manifest_branch_contents(&main) + .await + .unwrap() + .len(), + 2 + ); + let before = stored_files(dir.path()); + for check in [true, false] { + let result = upgrade_storage( + root, + UpgradeOptions { + check, + to_format: None, + }, + ) + .await + .unwrap(); + assert_eq!(result.outcome, UpgradeOutcome::AlreadyCurrent, "{result:?}"); + assert_eq!(stored_files(dir.path()), before); + } + let reopened = Omnigraph::open(root).await.unwrap(); + assert_eq!( + reopened.branch_list().await.unwrap(), + ["main", "child", "parent"] + ); + drop(reopened); + let retired = physical + .iter() + .find(|(_, contents)| contents.metadata.contains_key(RETIREMENT_KEY)) + .unwrap(); + let mut malformed = retired.1.metadata.clone(); + malformed.insert(RETIREMENT_KEY.into(), "{}".into()); + main.branches() + .replace_metadata(retired.0, malformed) + .await + .unwrap(); + let before = stored_files(dir.path()); + let result = upgrade_storage( + root, + UpgradeOptions { + check: true, + to_format: None, + }, + ) + .await + .unwrap(); + assert_eq!(result.outcome, UpgradeOutcome::CheckFailed, "{result:?}"); + assert_eq!(stored_files(dir.path()), before); +} + +#[tokio::test] +async fn storage_upgrade_legacy_source_refuses_reserved_retirement_metadata_without_effects() { + #[cfg(feature = "failpoints")] + let _scenario = crate::failpoints::FailScenario::setup(); + for source in [6, 7] { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_str().unwrap(); + synthetic_v6_fixture(root).await; + if source == 7 { + let converted = upgrade_storage( + root, + UpgradeOptions { + check: false, + to_format: Some(7), + }, + ) + .await + .unwrap(); + assert_eq!( + converted.outcome, + UpgradeOutcome::Completed, + "{converted:?}" + ); + } + let main = open(root, None).await.unwrap(); + main.branches() + .replace_metadata( + "feature", + HashMap::from([(RETIREMENT_KEY.into(), "{}".into())]), + ) + .await + .unwrap(); + let before = stored_files(dir.path()); + for check in [true, false] { + let result = upgrade_storage( + root, + UpgradeOptions { + check, + to_format: None, + }, + ) + .await + .unwrap(); + assert_eq!(result.outcome, UpgradeOutcome::CheckFailed, "{result:?}"); + assert!( + result + .findings + .iter() + .any(|finding| finding.message.contains("reserved retirement metadata")) + ); + assert_eq!(stored_files(dir.path()), before); + } + } +} diff --git a/crates/omnigraph/src/db/mod.rs b/crates/omnigraph/src/db/mod.rs index 686ed5e0..4663287f 100644 --- a/crates/omnigraph/src/db/mod.rs +++ b/crates/omnigraph/src/db/mod.rs @@ -20,10 +20,7 @@ pub use omnigraph::{ Omnigraph, OpenMode, PendingIndex, RebuiltFullTextIndex, RepairAction, RepairClassification, RepairOptions, RepairStats, SchemaApplyOptions, SchemaApplyResult, SkipReason, }; -pub(crate) use omnigraph::{ - DeferredTableFork, ForkRefStatus, WriteAuthorityToken, WriteTxn, - classify_fork_ref_with_references, force_delete_orphan_ref, -}; +pub(crate) use omnigraph::{DeferredTableFork, WriteAuthorityToken, WriteTxn}; pub(crate) use omnigraph::{export_blob_values, logical_row_image}; pub(crate) use schema_state::SchemaContractText; diff --git a/crates/omnigraph/src/db/omnigraph.rs b/crates/omnigraph/src/db/omnigraph.rs index da448b9f..36cc4dd7 100644 --- a/crates/omnigraph/src/db/omnigraph.rs +++ b/crates/omnigraph/src/db/omnigraph.rs @@ -5,7 +5,6 @@ use std::sync::Arc; use arc_swap::ArcSwap; use arrow_array::{Array, RecordBatch, StringArray, StructArray, UInt64Array, new_null_array}; use arrow_schema::{DataType, Field, Schema}; -use futures::StreamExt; use lance::Dataset; use lance::blob::{BlobArrayBuilder, blob_field}; use lance::dataset::scanner::ColumnOrdering; @@ -30,10 +29,6 @@ use crate::storage::{ use crate::storage_layer::SnapshotHandle; use crate::table_store::TableStore; -// Bound independent control-plane reads and per-table reclaim without -// multiplying the existing branch/table gate envelope. -const BRANCH_CONTROL_IO_CONCURRENCY: usize = 8; - mod export; mod optimize; mod repair; @@ -48,10 +43,7 @@ pub use repair::{ DatasetRepairStats, RepairAction, RepairClassification, RepairOptions, RepairStats, }; pub use schema_apply::SchemaApplyOptions; -pub(crate) use table_ops::{ - DeferredTableFork, ForkRefStatus, OpenedForMutation, classify_fork_ref_with_references, - force_delete_orphan_ref, -}; +pub(crate) use table_ops::{DeferredTableFork, OpenedForMutation}; pub use table_ops::{FullTextIndexRebuildResult, PendingIndex, RebuiltFullTextIndex}; use super::commit_graph::GraphCommit; @@ -265,11 +257,6 @@ pub struct Omnigraph { /// The mutex serializes captures — the schema serial queue already /// serializes merges and branch controls at capture time. merge_authority_cache: tokio::sync::Mutex>, - /// In-flight background fork reclaims spawned by `branch_delete`, keyed - /// by the deleted branch name; joined by - /// [`Self::wait_for_fork_reclaims`]. Dispatch prunes settled entries, so - /// the vec is bounded by concurrent deletes. - fork_reclaims: std::sync::Mutex)>>, /// Optional policy checker for engine-layer enforcement (MR-722). /// `None` = no enforcement; mutating methods are unconditionally /// allowed (this is the embedded/dev default). `Some` = every @@ -600,7 +587,6 @@ impl Omnigraph { })), write_queue, merge_authority_cache: tokio::sync::Mutex::new(None), - fork_reclaims: std::sync::Mutex::new(Vec::new()), policy: None, embedding: Arc::new(tokio::sync::OnceCell::new()), embedding_config: None, @@ -792,7 +778,6 @@ impl Omnigraph { })), write_queue, merge_authority_cache: tokio::sync::Mutex::new(None), - fork_reclaims: std::sync::Mutex::new(Vec::new()), policy: None, embedding: Arc::new(tokio::sync::OnceCell::new()), embedding_config: None, @@ -2984,317 +2969,12 @@ impl Omnigraph { Ok(()) } - async fn ensure_branch_delete_safe( - &self, - control: &GraphCoordinator, - branch: &str, - branches: &[String], - natives: &[String], - descendants: &[String], - ) -> Result<()> { - // Surviving branches inherit forks by native ref, so dependency - // detection compares against the delete target's native name. - let delete_target_native = control.native_branch().ok_or_else(|| { - OmniError::manifest_internal(format!("branch '{branch}' resolved without a native ref")) - })?; - 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" - ))); - } - - if let Some(descendant) = descendants.first() { - return Err(OmniError::manifest_conflict(format!( - "cannot delete branch '{}' because descendant branch '{}' still depends on it", - branch, descendant - ))); - } - - // Dependency detection reads ONLY each surviving branch's manifest - // `table_branch` entries. The schema-control gate held by the caller - // serializes native branch create/delete, while the target branch and - // table gates prevent a writer from creating new target-owned state. - // An ordinary write to a surviving branch can only replace an inherited - // target fork with that branch's own fork, so a concurrent write can - // make this check conservatively stale-true, never stale-false. The - // cold proof therefore does not need a discarded full incarnation - // capture. Reusing an already loaded snapshot does require a fresh - // incarnation probe, which replaces the cold manifest reconstruction. - // General coordinator/OCC/feed opens retain the coherent incarnation - // capture required by RFC-030. - let session = self.control_session(); - let candidates = branches - .iter() - .filter(|candidate| candidate.as_str() != branch) - .cloned() - .collect::>(); - let checks = candidates.into_iter().map(|other_branch| { - let session = &session; - async move { - let candidate_branch = Self::normalize_branch_name(&other_branch)?; - let candidate_native = match candidate_branch.as_deref() { - None => None, - Some(logical) => Some( - crate::branch_names::resolve_native_branch( - natives.iter().map(String::as_str), - logical, - )? - .ok_or_else(|| OmniError::BranchNotFound { - branch: logical.to_string(), - })?, - ), - }; - let depends = match self - .verified_dependency_snapshot_under_control_gates( - candidate_branch.as_deref(), - candidate_native.as_deref(), - ) - .await? - { - Some(snapshot) => snapshot.datasets().any(|entry| { - entry.native_dataset_branch.as_deref() == Some(delete_target_native) - }), - None => crate::db::manifest::ManifestCoordinator::branch_depends_on_delete_target_under_control_gates( - self.uri(), - candidate_native.as_deref(), - delete_target_native, - session, - ) - .await?, - }; - if depends { - return Err(OmniError::manifest_conflict(format!( - "cannot delete branch '{}' because branch '{}' still depends on it", - branch, other_branch - ))); - } - Ok(()) - } - }); - // Keep the existing deterministic refusal order while overlapping a - // bounded number of independent manifest reads. The gates remain held - // until every dependency proof succeeds and publication completes. - let mut checks = futures::stream::iter(checks).buffered(BRANCH_CONTROL_IO_CONCURRENCY); - while let Some(check) = checks.next().await { - check?; - } - - Ok(()) - } - - /// Reuse an already loaded dependency view only inside deletion's control - /// envelope. Its native ref must be the one just listed, and its complete - /// incarnation must still be current. Stale/missing views use the existing - /// manifest-only proof; unreadable authority still fails closed. This does - /// not populate or refresh the cache while surveying surviving branches. - async fn verified_dependency_snapshot_under_control_gates( - &self, - branch: Option<&str>, - native: Option<&str>, - ) -> Result> { - { - let coord = self.coordinator.read().await; - if coord.current_branch() == branch && coord.native_branch() == native { - let held = coord.manifest_incarnation(); - if coord.probe_latest_incarnation().await?.matches(&held) { - return Ok(Some(coord.snapshot())); - } - } - } - let cache = self.merge_authority_cache.lock().await; - if let Some((_, coord)) = cache.as_ref() - && coord.current_branch() == branch - && coord.native_branch() == native - { - let held = coord.manifest_incarnation(); - if coord.probe_latest_incarnation().await?.matches(&held) { - return Ok(Some(coord.snapshot())); - } - } - Ok(None) - } - - /// Best-effort reclaim of the per-table Lance forks a just-deleted branch - /// owned. Runs AFTER the manifest authority flip, so the forks are - /// unreachable orphans; failures are logged and swallowed, with the - /// `cleanup` reconciler as the backstop that converges any leftover. - /// `force_delete_branch` keeps a partially-reclaimed retry idempotent. - /// Owned arguments let the post-flip background task run it. - async fn cleanup_deleted_branch_tables( - store: TableStore, - branch: String, - owned_tables: Vec<(String, String)>, - ) { - let mut seen_paths = HashSet::new(); - let mut cleanup_targets = owned_tables - .into_iter() - .filter(|(_, table_path)| seen_paths.insert(table_path.clone())) - .collect::>(); - cleanup_targets.sort_by(|left, right| left.0.cmp(&right.0)); - - futures::stream::iter(cleanup_targets) - .for_each_concurrent(BRANCH_CONTROL_IO_CONCURRENCY, |(table_key, table_path)| { - let store = &store; - let branch = &branch; - async move { - let dataset_uri = store.dataset_uri(&table_path); - let outcome = match crate::failpoints::maybe_fail( - crate::failpoints::names::BRANCH_DELETE_BEFORE_TABLE_CLEANUP, - ) { - Ok(()) => store.force_delete_branch(&dataset_uri, branch).await, - Err(injected) => Err(injected), - }; - if let Err(err) = outcome { - tracing::warn!( - target: "omnigraph::branch_delete::cleanup", - branch = %branch, - table = %table_key, - error = %err, - "best-effort fork reclaim failed; cleanup will reconcile the orphan", - ); - } - } - }) - .await; - } - - /// Run the post-flip fork reclaim in a background task that holds the - /// request's schema, branch, and table gates until it settles, so - /// concurrent branch controls serialize behind the reclaim (the schema - /// gate is the load-bearing one for path-prefix creates and `cleanup`'s - /// reconciler). The export-destructive permit is deliberately NOT - /// carried: the reclaim removes only the deleted branch's fork trees, - /// which no live export cut can reference (cuts serve live branches, and - /// forks reference parent files, never the reverse), and the export gate - /// is a try-lock, so holding it would turn a post-response export into a - /// spurious resource-limit error instead of a wait. A reclaim that - /// exceeds its watchdog bound is abandoned; leftovers converge via - /// `cleanup` (ref still listed) or the next same-name branch create (ref - /// already removed, tree residue only). Without a tokio runtime the - /// reclaim runs inline (unbounded; the caller waits). - async fn dispatch_fork_reclaim( - &self, - branch: String, - owned_tables: Vec<(String, String)>, - schema_guard: crate::db::write_queue::QueueGuard, - branch_guard: crate::db::write_queue::QueueGuard, - table_guards: Vec, - ) { - // A branch that never forked a table has nothing to reclaim; return - // so the gates release at the response instead of on a task schedule. - if owned_tables.is_empty() { - return; - } - // Bounds how long a wedged object store can pin the carried gates; - // on expiry the reclaim future drops and the gates release. - const FORK_RECLAIM_ABANDON_AFTER: std::time::Duration = std::time::Duration::from_secs(600); - let store = self.table_store.clone(); - // Keeps the root write-queue manager alive (the process registry - // holds it Weak), so the carried gate slots keep excluding even if - // every Omnigraph handle for this root drops mid-reclaim. - let queue_manager = self.write_queue(); - let reclaim_branch = branch.clone(); - let reclaim = async move { - let _queue_manager = queue_manager; - let _schema_guard = schema_guard; - let _branch_guard = branch_guard; - let _table_guards = table_guards; - Self::cleanup_deleted_branch_tables(store, branch, owned_tables).await; - }; - match tokio::runtime::Handle::try_current() { - Ok(runtime) => { - let watchdog_branch = reclaim_branch.clone(); - let task = runtime.spawn(async move { - if tokio::time::timeout(FORK_RECLAIM_ABANDON_AFTER, reclaim) - .await - .is_err() - { - tracing::warn!( - target: "omnigraph::branch_delete::cleanup", - branch = %watchdog_branch, - timeout_secs = FORK_RECLAIM_ABANDON_AFTER.as_secs(), - "background fork reclaim abandoned at the watchdog bound; \ - a later cleanup or same-name branch create converges the leftovers", - ); - } - }); - let settled = { - let mut pending = self - .fork_reclaims - .lock() - .expect("fork reclaim registry poisoned"); - let held = std::mem::take(&mut *pending); - let (settled, live): (Vec<_>, Vec<_>) = held - .into_iter() - .partition(|(_, reclaim_task)| reclaim_task.is_finished()); - *pending = live; - pending.push((reclaim_branch, task)); - settled - }; - // Settled handles join without blocking; joining (instead of - // dropping) surfaces a panicked reclaim task's JoinError. - for (settled_branch, settled_task) in settled { - Self::join_fork_reclaim(settled_branch, settled_task).await; - } - } - Err(_) => reclaim.await, - } - } - - /// Join one fork reclaim task, logging a panic's JoinError (watchdog - /// expiry completes the task normally and logs its own warn). - async fn join_fork_reclaim(branch: String, task: tokio::task::JoinHandle<()>) { - if let Err(join_error) = task.await { - tracing::warn!( - target: "omnigraph::branch_delete::cleanup", - branch = %branch, - error = %join_error, - "background fork reclaim task did not run to completion; \ - a later cleanup or same-name branch create converges the leftovers", - ); - } - } - - /// Wait for the background fork reclaims dispatched through THIS handle - /// by [`Self::branch_delete`] to settle. Reclaim failures are logged, - /// never surfaced. Call it before process exit (the CLI does), before - /// dropping a current-thread runtime, or in tests that assert on fork - /// state. - /// - /// Intended for a single waiter: it drains the registry, so a concurrent - /// second caller may return before the drained reclaims settle. Must not - /// be called while holding write-queue gates: the joined tasks hold the - /// schema, branch, and dataset gates until they finish. - /// - /// # Panics - /// - /// Panics if the fork reclaim registry mutex is poisoned. - pub async fn wait_for_fork_reclaims(&self) { - let pending = { - let mut tasks = self - .fork_reclaims - .lock() - .expect("fork reclaim registry poisoned"); - std::mem::take(&mut *tasks) - }; - for (branch, task) in pending { - Self::join_fork_reclaim(branch, task).await; - } - } - - /// Flip the manifest branch authority and return the deleted - /// incarnation's native ref with its owned `(type_key, dataset_path)` - /// forks for the caller's post-flip reclaim. + /// Remove the captured manifest branch authority; cleanup owns table forks. async fn delete_captured_branch_storage( &self, branch: &str, target: &mut GraphCoordinator, - ) -> Result<(String, Vec<(String, String)>)> { + ) -> Result<()> { let active = self .coordinator .read() @@ -3308,15 +2988,6 @@ impl Omnigraph { ))); } - let native = target.native_branch().map(str::to_string).ok_or_else(|| { - OmniError::manifest_internal(format!("branch '{branch}' resolved without a native ref")) - })?; - let branch_snapshot = target.snapshot(); - let owned_tables = branch_snapshot - .datasets() - .filter(|entry| entry.native_dataset_branch.as_deref() == Some(native.as_str())) - .map(|entry| (entry.type_key.clone(), entry.dataset_path.clone())) - .collect::>(); let expected_identifier = target.branch_identifier().await?; // Authority removal is the logical branch deletion. Lance tree cleanup @@ -3331,10 +3002,7 @@ impl Omnigraph { // old branch-incarnation handles/topology can never leak into a later // recreation, while a failed control leaves warm state untouched. self.invalidate_read_caches().await; - // The reclaim happens post-flip in the caller's background task, - // addressed by this incarnation's native ref: a late-settling delete - // can only ever touch dead bytes. - Ok((native, owned_tables)) + Ok(()) } pub(crate) fn normalize_branch_name(branch: &str) -> Result> { @@ -3533,9 +3201,8 @@ impl Omnigraph { /// deletion of protected branches (e.g. deny BranchDelete against /// `main`). /// - /// Returns at the manifest authority flip, the logical deletion; the - /// best-effort reclaim of the branch's per-dataset Lance forks continues - /// in a background task joined by [`Self::wait_for_fork_reclaims`]. + /// Returns after the manifest authority flip. Per-table Lance forks remain + /// available until explicit cleanup proves they are unused. pub async fn branch_delete_as(&self, name: &str, actor: Option<&str>) -> Result<()> { self.enforce( omnigraph_policy::PolicyAction::BranchDelete, @@ -3552,11 +3219,11 @@ impl Omnigraph { crate::failpoints::maybe_fail( crate::failpoints::names::BRANCH_CONTROL_POST_RECOVERY_BARRIER, )?; - let schema_guard = self + let _schema_guard = self .write_queue() .acquire(&crate::db::manifest::schema_apply_serial_queue_key()) .await; - let branch_guard = self.write_queue().acquire_branch(Some(&branch)).await; + let _branch_guard = self.write_queue().acquire_branch(Some(&branch)).await; // Purge only after taking the branch gate. Merge capture takes the // same branch-gate -> cache-lock order, so no later insert for this // incarnation can race between invalidation and deletion. @@ -3572,7 +3239,7 @@ impl Omnigraph { let control_catalog = self.build_accepted_catalog_with_schema_gate_held().await?; let table_queue_keys = self.table_queue_keys_for_branches(&[Some(branch.clone())], &control_catalog); - let table_guards = self.write_queue().acquire_many(&table_queue_keys).await; + let _table_guards = self.write_queue().acquire_many(&table_queue_keys).await; self.ensure_branch_delete_recovery_safe_under_gates(&branch) .await?; crate::failpoints::maybe_fail(crate::failpoints::names::BRANCH_DELETE_POST_TABLE_GATES)?; @@ -3582,43 +3249,8 @@ impl Omnigraph { .open_coordinator_for_branch(Some(branch.as_str())) .await?; validate_bound_catalog_against_snapshot(&control_catalog, &target_control.snapshot())?; - // One ref listing serves existence, namespace, ancestry and every - // candidate's native ref for the dependency probe. - let (natives, descendants) = target_control - .native_branches_and_descendants(&branch) - .await?; - let branches: Vec = std::iter::once("main".to_string()) - .chain( - natives - .iter() - .map(|native| crate::branch_names::logical_branch_name(native).to_string()) - .filter(|logical| !crate::db::is_internal_system_branch(logical)), - ) - .collect(); - if !branches.iter().any(|candidate| candidate == &branch) { - return Err(OmniError::manifest_not_found(format!( - "branch '{}' not found", - branch - ))); - } - - self.ensure_branch_delete_safe(&target_control, &branch, &branches, &natives, &descendants) - .await?; - let (native, owned_tables) = self - .delete_captured_branch_storage(&branch, &mut target_control) - .await?; - // Post-flip: hand the request's gates to the background reclaim. - // Forks are named by the deleted incarnation's native ref, so the - // reclaim addresses that name, never the reusable logical one. - self.dispatch_fork_reclaim( - native, - owned_tables, - schema_guard, - branch_guard, - table_guards, - ) - .await; - Ok(()) + self.delete_captured_branch_storage(&branch, &mut target_control) + .await } pub async fn get_commit(&self, commit_id: &str) -> Result { @@ -3713,9 +3345,18 @@ impl Omnigraph { source_branch: Option<&str>, source_version: u64, active_branch: &str, - operation_id: Option<&str>, + _operation_id: Option<&str>, ) -> Result { - match table_ops::fork_dataset_from_entry_state( + let canonical_path = crate::db::manifest::table_path_for_identity(table_key, identity)?; + let canonical_full_path = self.storage().dataset_uri(&canonical_path); + if full_path != canonical_full_path { + return Err(OmniError::manifest_read_set_changed( + format!("fork_target_dataset_path:{identity}"), + Some(canonical_full_path), + Some(full_path.to_string()), + )); + } + table_ops::fork_dataset_from_entry_state( self, table_key, full_path, @@ -3723,23 +3364,7 @@ impl Omnigraph { source_version, active_branch, ) - .await? - { - crate::storage_layer::ForkOutcome::Created(ds) => Ok(ds), - crate::storage_layer::ForkOutcome::RefAlreadyExists => { - table_ops::reclaim_orphaned_fork_and_refork( - self, - table_key, - identity, - full_path, - source_branch, - source_version, - active_branch, - operation_id, - ) - .await - } - } + .await } pub(crate) async fn reopen_for_mutation( @@ -4987,7 +4612,9 @@ edge WorksAt: Person -> Company published_dataset_version: state.version, native_dataset_branch: table_branch, entity_count: state.row_count, - version_metadata: state.version_metadata, + version_metadata: state + .version_metadata + .with_table_fork_owner(db.snapshot().await.native_branch()), }]) .await .unwrap(); diff --git a/crates/omnigraph/src/db/omnigraph/optimize.rs b/crates/omnigraph/src/db/omnigraph/optimize.rs index fe2d40db..41e2ed70 100644 --- a/crates/omnigraph/src/db/omnigraph/optimize.rs +++ b/crates/omnigraph/src/db/omnigraph/optimize.rs @@ -58,9 +58,10 @@ fn maint_concurrency() -> usize { /// time cutoff AND the version cutoff are removed). #[derive(Debug, Clone, Default)] pub struct CleanupPolicyOptions { - /// Keep this many most-recent versions per dataset. + /// Keep this many most-recent versions when pruning retained datasets. + /// This count does not retain wholly unused forks or count graph commits. pub keep_versions: Option, - /// Only remove versions older than this duration. + /// Only remove versions and unused fork objects older than this duration. pub older_than: Option, } @@ -318,6 +319,7 @@ pub async fn optimize_all_datasets(db: &Omnigraph) -> Result = all_table_keys(&cleanup_catalog) .into_iter() .filter_map(|table_key| { @@ -1239,21 +1221,7 @@ pub async fn cleanup_all_datasets( }) .collect(); - // Schema gate stability means no native branch create/delete can change this - // set between enumeration and acquisition. Include main canonically as None; - // `all_branches` returns the user-facing "main" spelling. - let mut graph_branches = db - .coordinator - .read() - .await - .all_branches() - .await? - .into_iter() - .map(|branch| if branch == "main" { None } else { Some(branch) }) - .collect::>(); - graph_branches.push(None); - graph_branches.sort(); - graph_branches.dedup(); + let graph_branches = cleanup_graph_branches(db).await?; let _cleanup_branch_guards = db.write_queue().acquire_branches(&graph_branches).await; let gc_queue_keys = db.table_queue_keys_for_branches(&graph_branches, &cleanup_catalog); let _cleanup_table_guards = db.write_queue().acquire_many(&gc_queue_keys).await; @@ -1297,10 +1265,7 @@ pub async fn cleanup_all_datasets( "cleanup could not classify live branch '{branch_label}'; refusing version GC: {err}" )) })?; - for entry in branch_snapshot - .datasets() - .filter(|entry| entry.native_dataset_branch.is_none()) - { + for entry in branch_snapshot.datasets() { // 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 @@ -1311,6 +1276,9 @@ pub async fn cleanup_all_datasets( dataset_subject(&entry.type_key), entry.published_dataset_version )) })?; + if entry.native_dataset_branch.is_some() { + continue; + } let full_path = format!("{}/{}", db.root_uri, entry.dataset_path); if branch_target.is_none() { let head = db.storage().open_dataset_head(&full_path, None).await?; @@ -1332,6 +1300,22 @@ pub async fn cleanup_all_datasets( } let before_timestamp = options.older_than.map(|d| crate::dst_clock::now_utc() - d); + let reconciled = reconcile_orphaned_branches_under_control_gates(db, before_timestamp).await?; + if !reconciled.reclaimed.is_empty() { + tracing::info!( + count = reconciled.reclaimed.len(), + reclaimed = ?reconciled.reclaimed, + "cleanup reconciled orphaned branch forks" + ); + } + if !reconciled.failures.is_empty() { + tracing::warn!( + count = reconciled.failures.len(), + failures = ?reconciled.failures, + "cleanup could not reconcile some orphaned forks; will retry next cleanup" + ); + } + let keep_versions = options.keep_versions; let table_tasks = table_tasks .into_iter() @@ -1444,94 +1428,281 @@ pub struct BranchReconcileStats { pub failures: Vec<(String, String)>, } -/// Drop every per-table Lance branch fork the manifest does not reference. -/// Graph lineage lives in `__manifest`; the retired standalone commit datasets -/// have no branch-ref cleanup path here. -/// -/// Two origins produce a manifest-unreferenced fork: -/// 1. A `branch_delete` flips the manifest authority (atomic) but a -/// downstream best-effort reclaim does not complete — the whole branch is -/// gone from the manifest, but a `tree/{branch}/` ref lingers. -/// 2. A first-write fork (or a merge fork) creates the branch ref before the -/// manifest publish, then the writer dies / is cancelled — the branch is -/// still a live manifest branch, but the manifest's snapshot of it does -/// not place *this table* on the branch. -/// -/// The write path self-heals (2) on the next write to the table -/// (`reclaim_orphaned_fork_and_refork`); this is the guaranteed-convergence -/// backstop that also covers (1) and any table the write path never revisits. -/// -/// Liveness is graph-wide and keyed by immutable table identity plus native -/// ref: any live snapshot or pending recovery pin protects the whole ref, -/// including lazy children of a branch whose owner adopted another pointer. -/// `main` and internal/system branches are never candidates. Lance refuses to -/// force-delete a branch with referencing descendants, so children are dropped -/// before parents (longest name first). The proof is derived under cleanup's -/// schema-control gate; no separate registry or persistent authority is added. +/// Collect unreferenced table forks under cleanup's complete writer gates. #[cfg(all(test, feature = "failpoints"))] pub async fn reconcile_orphaned_branches(db: &Omnigraph) -> Result { + let _schema = db + .write_queue() + .acquire(&crate::db::manifest::schema_apply_serial_queue_key()) + .await; let catalog = db.catalog(); - reconcile_orphaned_branches_with_catalog(db, &catalog).await + let graph_branches = cleanup_graph_branches(db).await?; + let _branches = db.write_queue().acquire_branches(&graph_branches).await; + let table_keys = db.table_queue_keys_for_branches(&graph_branches, &catalog); + let _tables = db.write_queue().acquire_many(&table_keys).await; + reconcile_orphaned_branches_under_control_gates(db, None).await +} + +async fn cleanup_graph_branches(db: &Omnigraph) -> Result>> { + let mut branches = db + .coordinator + .read() + .await + .all_branches() + .await? + .into_iter() + .map(|branch| if branch == "main" { None } else { Some(branch) }) + .collect::>(); + branches.push(None); + branches.sort(); + branches.dedup(); + Ok(branches) +} + +struct NativeForkInventory { + refs: std::collections::HashMap, + trees: std::collections::BTreeSet, + tagged: std::collections::HashSet, + age_retained: std::collections::HashSet, +} + +impl NativeForkInventory { + fn depends_on(&self, child: &str, ancestor: &str) -> bool { + if child == ancestor { + return false; + } + if child + .strip_prefix(ancestor) + .is_some_and(|suffix| suffix.starts_with('/')) + { + return true; + } + let Some(child_ref) = self.refs.get(child) else { + return false; + }; + child_ref.parent_branch.as_deref() == Some(ancestor) + || self.refs.get(ancestor).is_some_and(|ancestor_ref| { + child_ref + .identifier + .find_referenced_version(&ancestor_ref.identifier) + .is_some() + }) + } + + fn retain_dependencies(&self, retained: &mut std::collections::HashSet) { + loop { + let ancestors = self + .trees + .iter() + .filter(|candidate| { + !retained.contains(*candidate) + && retained.iter().any(|root| self.depends_on(root, candidate)) + }) + .cloned() + .collect::>(); + if ancestors.is_empty() { + break; + } + retained.extend(ancestors); + } + } +} + +fn is_native_layout_directory(part: &str) -> bool { + matches!( + part, + "_versions" | "_transactions" | "data" | "_deletions" | "_indices" + ) +} + +fn native_tree_prefix(relative: &str) -> Result { + let segments = relative.split('/').collect::>(); + segments + .iter() + .enumerate() + .find_map(|(index, part)| { + if index == 0 || index + 1 == segments.len() || !is_native_layout_directory(part) { + return None; + } + let branch = segments[..index].join("/"); + lance::dataset::refs::check_valid_branch(&branch) + .is_ok() + .then_some(branch) + }) + .ok_or_else(|| { + OmniError::manifest_conflict(format!( + "cleanup cannot identify a native fork tree for '{relative}'" + )) + }) +} + +async fn native_fork_inventory( + dataset: &lance::Dataset, + before_timestamp: Option>, +) -> Result { + let refs = crate::branch_control::list_all_branch_contents(dataset).await?; + let tagged = dataset + .tags() + .list() + .await + .map_err(OmniError::storage)? + .into_values() + .filter_map(|tag| tag.branch) + .collect(); + let root = dataset + .branch_location() + .find_main() + .map_err(OmniError::storage)? + .path; + let tree = root.clone().join("tree"); + let prefix = format!("{tree}/"); + let store = dataset + .object_store(None) + .await + .map_err(OmniError::storage)?; + let mut files = store.read_dir_all(&tree, None); + let mut trees = refs + .keys() + .cloned() + .collect::>(); + let mut age_retained = std::collections::HashSet::new(); + while let Some(file) = files.next().await { + let file = file.map_err(OmniError::storage)?; + let relative = file + .location + .as_ref() + .strip_prefix(&prefix) + .ok_or_else(|| { + OmniError::manifest_conflict(format!( + "cleanup listed native tree object outside '{tree}'" + )) + })?; + let matching_refs = refs + .keys() + .filter(|branch| { + relative + .strip_prefix(branch.as_str()) + .is_some_and(|suffix| { + suffix.strip_prefix('/').is_some_and(|path| { + path.split('/') + .next() + .is_some_and(is_native_layout_directory) + }) + }) + }) + .collect::>(); + let recent = before_timestamp.is_some_and(|cutoff| file.last_modified >= cutoff); + if !matching_refs.is_empty() { + if recent { + age_retained.extend(matching_refs.into_iter().cloned()); + } + continue; + } + let branch = native_tree_prefix(relative)?; + if recent { + age_retained.insert(branch.clone()); + } + trees.insert(branch); + } + if let Some(cutoff) = before_timestamp { + let mut observed_refs = std::collections::HashSet::new(); + { + let directory = root.clone().join("_refs").join("branches"); + let expected = refs + .keys() + .map(|name| (directory.clone().join(format!("{name}.json")), name)) + .collect::>(); + let mut objects = store.read_dir_all(&directory, None); + while let Some(object) = objects.next().await { + let object = object.map_err(OmniError::storage)?; + if !object.location.as_ref().ends_with(".json") { + continue; + } + let name = expected.get(&object.location).ok_or_else(|| { + OmniError::manifest_conflict(format!( + "cleanup age census found an unclassified native ref '{}'", + object.location + )) + })?; + observed_refs.insert((*name).clone()); + if object.last_modified >= cutoff { + age_retained.insert((*name).clone()); + } + } + } + if let Some(missing) = refs.keys().find(|name| !observed_refs.contains(*name)) { + return Err(OmniError::manifest_conflict(format!( + "cleanup age census cannot locate native ref '{missing}'" + ))); + } + } + Ok(NativeForkInventory { + refs, + trees, + tagged, + age_retained, + }) } -async fn reconcile_orphaned_branches_with_catalog( +async fn reconcile_orphaned_branches_under_control_gates( db: &Omnigraph, - catalog: &omnigraph_compiler::catalog::Catalog, + before_timestamp: Option>, ) -> Result { let resolved = db.resolved_branch_target(None).await?; - let snapshot = resolved.snapshot; - let table_targets: Vec<(crate::db::manifest::TableIdentity, String, String)> = - all_table_keys(catalog) - .into_iter() - .filter_map(|table_key| { - let entry = snapshot.dataset(&table_key)?; - let full_path = format!("{}/{}", db.root_uri, entry.dataset_path); - Some((entry.identity, table_key, full_path)) - }) - .collect(); - + let live_identities = resolved + .snapshot + .datasets() + .map(|entry| entry.identity) + .collect::>(); + let mut registrations = + crate::db::manifest::ManifestCoordinator::table_registrations_under_control_gates( + db.root_uri(), + &db.control_session(), + ) + .await?; + registrations.sort_by_key(|registration| registration.identity); + let table_targets = registrations.into_iter().map(|registration| { + let full_path = format!("{}/{}", db.root_uri, registration.table_path); + (registration.identity, registration.table_key, full_path) + }); let mut stats = BranchReconcileStats::default(); - // Capture one complete proof only when a native table ref needs checking. - // The held schema gate keeps it valid for the rest of this sweep, including - // lazy borrowers whose logical owner no longer uses their pinned ref. let mut references = None; - - // Per-table fault isolation: one table's transient failure is recorded and - // logged, never aborting the rest of the sweep. + let sidecars = crate::db::manifest::list_sidecars(db.root_uri(), db.storage_adapter()).await?; let storage = db.storage(); for (identity, table_key, full_path) in table_targets { - let listed = match storage.list_native_branches(&full_path).await { - Ok(listed) => listed, - Err(err) => { - tracing::warn!( - target: "omnigraph::cleanup", - table = %table_key, - error = %err, - "listing branches failed during reconcile; skipping dataset", - ); - stats.failures.push((table_key.clone(), err.to_string())); + let inventory = async { + let handle = match storage.open_dataset_head(&full_path, None).await { + Ok(handle) => handle, + Err(error) + if !live_identities.contains(&identity) + && error.storage_failure().is_some_and(|failure| { + failure.kind == omnigraph_storage::StorageFailureKind::NotFound + }) => + { + return Ok(None); + } + Err(error) => return Err(error), + }; + native_fork_inventory(handle.dataset(), before_timestamp) + .await + .map(Some) + } + .await; + let inventory = match inventory { + Ok(Some(inventory)) => inventory, + Ok(None) => continue, + Err(error) => { + stats.failures.push((table_key.clone(), error.to_string())); continue; } }; - - // Main and internal refs cannot be reclaimed. An empty candidate - // iterator needs no graph-wide liveness reads and consumes no proof hook. - let mut listed = listed - .into_iter() - .filter(|branch| { - branch != "main" - && !crate::db::is_internal_system_branch( - crate::branch_names::logical_branch_name(branch), - ) - }) - .peekable(); - if listed.peek().is_none() { + if inventory.trees.is_empty() { continue; } if references.is_none() { let captured = match crate::failpoints::maybe_fail( crate::failpoints::names::CLEANUP_RESOLVE_BRANCH_SNAPSHOT, - ) { + ).and_then(|()| crate::failpoints::maybe_fail(crate::failpoints::names::CLASSIFY_FRESH_READ)) { Ok(()) => { crate::db::manifest::ManifestCoordinator::native_fork_references_under_control_gates( db.root_uri(), @@ -1551,85 +1722,137 @@ async fn reconcile_orphaned_branches_with_catalog( } } } - let references = references.as_ref().expect("candidate refs require a proof"); + let references = references + .as_ref() + .expect("native trees require live roots"); + let mut retained = inventory.tagged.clone(); + retained.extend( + inventory + .trees + .iter() + .filter(|native| { + native.as_str() == "main" + || crate::db::is_internal_system_branch(native) + || references.contains_tree(identity, native) + || sidecars.iter().any(|sidecar| { + sidecar.tables.iter().any(|pin| { + pin.identity == identity + && pin.table_branch.as_deref() == Some(native.as_str()) + }) + }) + }) + .cloned(), + ); + collect_native_forks(db, &full_path, &table_key, inventory, retained, &mut stats).await; + } + reconcile_retired_manifest_forks(db, before_timestamp, &mut stats).await; + Ok(stats) +} - // Decide per (table, branch) whether the fork is an orphan. - let mut orphans: Vec = Vec::new(); - for branch in listed { - if !references.contains(identity, &branch) { - orphans.push(branch); - } +async fn collect_native_forks( + db: &Omnigraph, + full_path: &str, + table_key: &str, + mut inventory: NativeForkInventory, + mut retained: std::collections::HashSet, + stats: &mut BranchReconcileStats, +) { + retained.extend(inventory.age_retained.iter().cloned()); + inventory.retain_dependencies(&mut retained); + let protected_zombie_roots = retained + .iter() + .filter(|branch| !inventory.refs.contains_key(*branch)) + .filter_map(|branch| branch.split('/').next().map(str::to_string)) + .collect::>(); + retained.extend( + inventory + .trees + .iter() + .filter(|branch| { + branch + .split('/') + .next() + .is_some_and(|root| protected_zombie_roots.contains(root)) + }) + .cloned(), + ); + inventory.retain_dependencies(&mut retained); + let mut candidates = inventory + .trees + .iter() + .filter(|branch| !retained.contains(*branch)) + .cloned() + .collect::>(); + loop { + let leaves = candidates + .iter() + .filter(|candidate| { + !inventory + .trees + .iter() + .any(|other| inventory.depends_on(other, candidate)) + }) + .cloned() + .collect::>(); + if leaves.is_empty() { + break; } - // Children before parents (longest name first) so Lance's referenced- - // parent RefConflict cannot block reclamation. - orphans.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b))); - - for branch in orphans { - // Serialize against in-process live writers before destroying a ref. - // A first-write fork holds the per-(table, branch) write queue from - // before the fork through the manifest publish; on a LIVE branch its - // in-flight fork looks exactly like an origin-2 orphan (manifest not - // yet advanced). Acquire the same queue so cleanup waits for any such - // writer, then RE-VALIDATE under the queue with a fresh read: if the - // writer published in the meantime (table now placed on the branch), - // it is no longer an orphan — skip it. (Cross-process writers remain - // the documented one-winner-CAS gap.) One key held at a time → no - // lock-order inversion against multi-table `acquire_many` writers. - let _guard = db - .write_queue() - .acquire(&( - table_key.clone(), - Some(crate::branch_names::logical_branch_name(&branch).to_string()), - )) - .await; - // The schema gate keeps the graph-wide proof valid; re-check - // pending recovery ownership under the table queue before deletion. - match super::table_ops::classify_fork_ref_with_references( - db, identity, &branch, None, references, - ) - .await - { - super::table_ops::ForkRefStatus::Orphan => {} - super::table_ops::ForkRefStatus::Legitimate - | super::table_ops::ForkRefStatus::Borrowed => continue, - super::table_ops::ForkRefStatus::Indeterminate => { - tracing::warn!( - target: "omnigraph::cleanup", - table = %table_key, - branch = %branch, - "fresh re-check inconclusive during reconcile; skipping to avoid \ - destroying a possibly-live fork (will retry next cleanup)", - ); - stats.failures.push(( - table_key.clone(), - format!("indeterminate fork status for {branch}"), - )); - continue; - } - } + for branch in leaves { + candidates.remove(&branch); let outcome = match crate::failpoints::maybe_fail( crate::failpoints::names::CLEANUP_RECONCILE_FORK, ) { - Ok(()) => storage.force_delete_branch(&full_path, &branch).await, + Ok(()) => db.storage().force_delete_branch(full_path, &branch).await, Err(injected) => Err(injected), }; match outcome { - Ok(()) => stats.reclaimed.push((table_key.clone(), branch)), - Err(err) => { - tracing::warn!( - target: "omnigraph::cleanup", - table = %table_key, - branch = %branch, - error = %err, - "reclaiming orphaned fork failed; will retry next cleanup", - ); - stats.failures.push((table_key.clone(), err.to_string())); + Ok(()) => { + inventory.trees.remove(&branch); + inventory.refs.remove(&branch); + stats.reclaimed.push((table_key.to_string(), branch)); + } + Err(error) => { + stats + .failures + .push((table_key.to_string(), format!("{branch}: {error}"))); } } } } + for branch in candidates { + stats.failures.push(( + table_key.to_string(), + format!("cleanup retained '{branch}' because a native dependency remains"), + )); + } +} - Ok(stats) +async fn reconcile_retired_manifest_forks( + db: &Omnigraph, + before_timestamp: Option>, + stats: &mut BranchReconcileStats, +) { + let full_path = crate::db::manifest::manifest_uri(db.root_uri()); + let captured = async { + let handle = db.storage().open_dataset_head(&full_path, None).await?; + let mut retained = + crate::branch_control::list_live_manifest_branch_contents(handle.dataset()) + .await? + .into_keys() + .collect::>(); + let inventory = native_fork_inventory(handle.dataset(), before_timestamp).await?; + retained.extend(inventory.tagged.iter().cloned()); + Ok::<_, OmniError>((inventory, retained)) + } + .await; + match captured { + Ok((inventory, retained)) => { + collect_native_forks(db, &full_path, "__manifest", inventory, retained, stats).await; + } + Err(error) => stats + .failures + .push(("__manifest".to_string(), error.to_string())), + } } pub(super) fn all_table_keys(catalog: &omnigraph_compiler::catalog::Catalog) -> Vec { diff --git a/crates/omnigraph/src/db/omnigraph/schema_apply.rs b/crates/omnigraph/src/db/omnigraph/schema_apply.rs index c3aaa945..975f47e1 100644 --- a/crates/omnigraph/src/db/omnigraph/schema_apply.rs +++ b/crates/omnigraph/src/db/omnigraph/schema_apply.rs @@ -518,6 +518,7 @@ where let table_path = crate::db::manifest::table_path_for_identity(table_key, identity)?; let planned = pre_minted_schema_transaction(0); recovery_pins.push(crate::db::manifest::SidecarTablePin { + table_fork_owner: None, identity, table_key: table_key.clone(), table_path: db.storage().dataset_uri(&table_path), @@ -570,6 +571,7 @@ where } let planned = pre_minted_schema_transaction(entry.published_dataset_version); recovery_pins.push(crate::db::manifest::SidecarTablePin { + table_fork_owner: None, identity, table_key: table_key.clone(), table_path: db.storage().dataset_uri(&entry.dataset_path), diff --git a/crates/omnigraph/src/db/omnigraph/table_ops.rs b/crates/omnigraph/src/db/omnigraph/table_ops.rs index d5d81c8c..2b0ecdce 100644 --- a/crates/omnigraph/src/db/omnigraph/table_ops.rs +++ b/crates/omnigraph/src/db/omnigraph/table_ops.rs @@ -118,10 +118,31 @@ pub(super) async fn failpoint_publish_table_head_without_index_rebuild_for_test( .1 .is_none() => { - Some(db.native_branch_for(name).await?) + let owner = db.native_branch_for(name).await?; + Some( + entry + .native_dataset_branch + .as_deref() + .filter(|fork| entry.version_metadata.is_table_fork_of(fork, &owner)) + .unwrap_or(&owner) + .to_string(), + ) } other => other.map(str::to_string), }; + let table_fork_owner = if table_branch == entry.native_dataset_branch { + entry + .version_metadata + .table_fork_owner() + .map(str::to_string) + } else { + crate::db::manifest::list_sidecars(db.root_uri(), db.storage_adapter()) + .await? + .iter() + .flat_map(|sidecar| sidecar.tables.iter()) + .find(|pin| pin.identity == entry.identity && pin.table_branch == table_branch) + .and_then(|pin| pin.table_fork_owner.clone()) + }; let full_path = format!("{}/{}", db.root_uri, entry.dataset_path); let ds = db .storage() @@ -134,7 +155,9 @@ pub(super) async fn failpoint_publish_table_head_without_index_rebuild_for_test( published_dataset_version: state.version, native_dataset_branch: table_branch.clone(), entity_count: state.row_count, - version_metadata: state.version_metadata, + version_metadata: state + .version_metadata + .with_table_fork_owner(table_fork_owner.as_deref()), }; let mut expected = crate::db::manifest::ExpectedTableVersions::new(); expected.insert( @@ -235,8 +258,12 @@ async fn maintain_indices_for_branch( continue; } let full_path = format!("{}/{}", db.root_uri, entry.dataset_path); - let first_touch = active_branch.is_some() - && entry.native_dataset_branch.as_deref() != native_active.as_deref(); + let first_touch = native_active.as_deref().is_some_and(|owner| { + !entry + .native_dataset_branch + .as_deref() + .is_some_and(|fork| entry.version_metadata.is_table_fork_of(fork, owner)) + }); let ds = if first_touch { // The inherited owner's HEAD may advance independently after this // graph branch was cut. Plan from the exact inherited snapshot, not @@ -244,7 +271,7 @@ async fn maintain_indices_for_branch( db.storage().open_snapshot_at_entry(entry).await? } else { db.storage() - .open_dataset_head(&full_path, native_active.as_deref()) + .open_dataset_head(&full_path, entry.native_dataset_branch.as_deref()) .await? }; let work = match mode { @@ -267,7 +294,8 @@ async fn maintain_indices_for_branch( expected_version: entry.published_dataset_version, post_commit_pin: entry.published_dataset_version + 1, confirmed_version: None, - table_branch: native_active.clone(), + table_branch: entry.native_dataset_branch.clone(), + table_fork_owner: native_active.clone(), }); pin_read_refs.push(entry.native_dataset_branch.clone()); if !first_touch { @@ -297,13 +325,17 @@ async fn maintain_indices_for_branch( continue; } let full_path = format!("{}/{}", db.root_uri, entry.dataset_path); - let first_touch = active_branch.is_some() - && entry.native_dataset_branch.as_deref() != native_active.as_deref(); + let first_touch = native_active.as_deref().is_some_and(|owner| { + !entry + .native_dataset_branch + .as_deref() + .is_some_and(|fork| entry.version_metadata.is_table_fork_of(fork, owner)) + }); let ds = if first_touch { db.storage().open_snapshot_at_entry(entry).await? } else { db.storage() - .open_dataset_head(&full_path, native_active.as_deref()) + .open_dataset_head(&full_path, entry.native_dataset_branch.as_deref()) .await? }; let work = match mode { @@ -324,7 +356,8 @@ async fn maintain_indices_for_branch( expected_version: entry.published_dataset_version, post_commit_pin: entry.published_dataset_version + 1, confirmed_version: None, - table_branch: native_active.clone(), + table_branch: entry.native_dataset_branch.clone(), + table_fork_owner: native_active.clone(), }); pin_read_refs.push(entry.native_dataset_branch.clone()); if !first_touch { @@ -368,7 +401,7 @@ async fn maintain_indices_for_branch( let queue_keys: Vec<(String, Option)> = recovery_pins .iter() - .map(|pin| (pin.table_key.clone(), pin.table_branch.clone())) + .map(|pin| (pin.table_key.clone(), active_branch.clone())) .collect(); let _schema_guard = db .write_queue() @@ -386,14 +419,6 @@ async fn maintain_indices_for_branch( ) .await?; let live_snapshot = db.revalidate_write_txn(&txn).await?; - let first_touch_references = if first_touch_sources.is_empty() { - None - } else { - Some(Box::pin(crate::db::manifest::ManifestCoordinator::native_fork_references_under_control_gates( - db.root_uri(), &db.control_session(), - )).await?) - }; - for pin in &recovery_pins { let prepared_entry = snapshot.dataset(&pin.table_key).ok_or_else(|| { OmniError::manifest_conflict(format!( @@ -445,32 +470,6 @@ async fn maintain_indices_for_branch( ds, ) .await?; - } else if let Some(source) = first_touch_sources.get(&pin.table_key) { - let target_branch = native_active.as_deref().ok_or_else(|| { - OmniError::manifest_internal(format!( - "first-touch index target '{}' has no active named branch", - pin.table_key, - )) - })?; - if first_touch_references - .as_ref() - .expect("first-touch liveness proof") - .contains(pin.identity, target_branch) - { - return Err(crate::db::manifest::detached_native_lineage_error( - &pin.table_key, - target_branch, - )); - } - let branches = crate::branch_control::list_branch_contents(source.dataset()).await?; - if branches.contains_key(target_branch) { - return Err(OmniError::manifest_conflict(format!( - "index target ref '{}:{}' already exists while the graph manifest still \ - inherits the table from another branch; refusing to claim unowned \ - physical state — inspect and remove the orphaned ref before retrying", - pin.table_key, target_branch, - ))); - } } } @@ -499,6 +498,21 @@ async fn maintain_indices_for_branch( let lineage = db .new_lineage_intent_for_branch(active_branch.as_deref(), actor) .await?; + for pin in &mut recovery_pins { + if first_touch_source_versions.contains_key(&pin.identity) { + let owner = native_active.as_deref().ok_or_else(|| { + OmniError::manifest_internal(format!( + "first-touch index target '{}' has no native branch ref", + pin.table_key, + )) + })?; + pin.table_branch = Some(crate::branch_names::table_fork_name( + owner, + txn.base.graph_manifest_version(), + &lineage.graph_commit_id, + )); + } + } let authority = crate::db::manifest::RecoveryAuthorityToken { branch_identifier: txn.authority.branch_identifier.clone(), graph_head: txn.authority.graph_head.clone(), @@ -544,46 +558,31 @@ async fn maintain_indices_for_branch( })?; let full_path = pin.table_path.clone(); let first_touch = first_touch_source_versions.contains_key(&pin.identity); - let (ds, resolved_branch) = match active_branch.as_deref() { - Some(active_branch) => { - if let Some(ds) = existing_targets.remove(&table_key) { - (ds, native_active.clone()) - } else { - first_touch_sources.remove(&table_key).ok_or_else(|| { - OmniError::manifest_internal(format!( - "missing first-touch source for index table '{}'", - table_key - )) - })?; - open_owned_dataset_for_branch_write( - db, - &table_key, - pin.identity, - &full_path, - entry.native_dataset_branch.as_deref(), - entry.published_dataset_version, - active_branch, - native_active.as_deref().ok_or_else(|| { - OmniError::manifest_internal(format!( - "first-touch index target '{}' has no native branch ref", - table_key - )) - })?, - crate::db::MutationOpKind::SchemaRewrite, - true, - ) - .await? - } - } - None => ( - existing_targets.remove(&table_key).ok_or_else(|| { - OmniError::manifest_internal(format!( - "missing verified existing target for main table '{}'", - table_key, - )) - })?, - None, - ), + let ds = if let Some(ds) = existing_targets.remove(&table_key) { + ds + } else { + first_touch_sources.remove(&table_key).ok_or_else(|| { + OmniError::manifest_internal(format!( + "missing first-touch source for index table '{}'", + table_key, + )) + })?; + let target = pin.table_branch.as_deref().ok_or_else(|| { + OmniError::manifest_internal(format!( + "first-touch index target '{}' has no saved fork name", + table_key, + )) + })?; + db.fork_dataset_from_entry_state_under_intent( + &table_key, + pin.identity, + &full_path, + entry.native_dataset_branch.as_deref(), + entry.published_dataset_version, + target, + Some(&recovery_operation_id), + ) + .await? }; let mut staged = if let Some(staged) = existing_staged.remove(&table_key) { @@ -637,9 +636,11 @@ async fn maintain_indices_for_branch( identity: pin.identity, type_key: table_key, published_dataset_version: state.version, - native_dataset_branch: resolved_branch, + native_dataset_branch: pin.table_branch.clone(), entity_count: state.row_count, - version_metadata: state.version_metadata, + version_metadata: state + .version_metadata + .with_table_fork_owner(pin.table_fork_owner.as_deref()), }); crate::failpoints::maybe_fail( crate::failpoints::names::ENSURE_INDICES_POST_TABLE_EFFECT, @@ -976,18 +977,6 @@ async fn plan_index_work_node( column: prop_name.clone(), }); } - // DEFERRED: a companion BTREE here would index-accelerate - // equality and `starts_with` on free-text Strings (Lance - // never consults an inverted index for either), and the - // staged machinery supports it (`IndexBuildSpec::BTree` - // explicit names; the same-column batch test). It is held - // back because Lance's second-generation shallow clones - // cannot read parent index files at all — every indexed read - // through a branch-of-a-branch fork hard-errors - // (lance-format/lance#7840), and the companion would widen - // that exposure to every `@key` equality lookup. Re-land when - // `lance_surface_guards::second_generation_branch_index_reads_fail_upstream` - // turns red (its panic message carries the checklist). } Some(NodePropIndexKind::Vector) => { if !db.storage().has_vector_index(ds, prop_name).await? { @@ -1135,9 +1124,6 @@ pub(super) async fn open_for_mutation_on_branch( .dataset(table_key) .ok_or_else(|| OmniError::manifest(missing_graph_type_at_snapshot(table_key)))?; let full_path = format!("{}/{}", db.root_uri, entry.dataset_path); - // Forks carry the branch's native ref name (`{logical}.{incarnation}`), - // never the logical name: a recreated branch must fork under a fresh - // physical name so a dead incarnation's reclaim can never touch it. let native_active = match resolved_branch.as_deref() { Some(branch) => Some(match snapshot.native_branch() { Some(native) => native.to_string(), @@ -1165,15 +1151,20 @@ pub(super) async fn open_for_mutation_on_branch( match resolved_branch.as_deref() { // Non-strict, table already on the active branch → no open, no fork. Some(_) - if entry.native_dataset_branch.is_some() - && entry.native_dataset_branch == native_active => + if entry + .native_dataset_branch + .as_deref() + .zip(native_active.as_deref()) + .is_some_and(|(fork, owner)| { + entry.version_metadata.is_table_fork_of(fork, owner) + }) => { return Ok(OpenedForMutation { identity: entry.identity, handle: None, expected_version: entry.published_dataset_version, full_path, - table_branch: native_active, + table_branch: entry.native_dataset_branch.clone(), pinned_native_ref: entry.native_dataset_branch.clone(), deferred_fork: None, }); @@ -1237,7 +1228,11 @@ pub(super) async fn open_for_mutation_on_branch( "branch '{active_branch}' resolved without a native ref" )) })?; - if txn.is_some() && entry.native_dataset_branch.as_deref() != Some(native_active) { + if txn.is_some() + && !entry.native_dataset_branch.as_deref().is_some_and(|fork| { + entry.version_metadata.is_table_fork_of(fork, native_active) + }) + { let ds = db.storage().open_snapshot_at_entry(entry).await?; return Ok(OpenedForMutation { identity: entry.identity, @@ -1259,10 +1254,12 @@ pub(super) async fn open_for_mutation_on_branch( &full_path, entry.native_dataset_branch.as_deref(), entry.published_dataset_version, + &entry.version_metadata, active_branch, native_active, op_kind, txn.is_some(), + snapshot.graph_manifest_version(), ) .await?; let version = ds.version(); @@ -1286,18 +1283,20 @@ pub(super) async fn open_owned_dataset_for_branch_write( full_path: &str, entry_branch: Option<&str>, entry_version: u64, + entry_version_metadata: &crate::db::manifest::TableVersionMetadata, active_branch: &str, native_active: &str, op_kind: crate::db::MutationOpKind, occ_enrolled: bool, + base_manifest_version: u64, ) -> Result<(SnapshotHandle, Option)> { // `active_branch` is the logical branch (manifest reads, gates, sidecars); // `native_active` is its native ref (Lance opens, forks, entry names). match entry_branch { - Some(branch) if branch == native_active => { + Some(branch) if entry_version_metadata.is_table_fork_of(branch, native_active) => { let ds = db .storage() - .open_dataset_head(full_path, Some(native_active)) + .open_dataset_head(full_path, Some(branch)) .await?; if op_kind.strict_pre_stage_version_check() { if occ_enrolled && ds.version() != entry_version { @@ -1312,58 +1311,54 @@ pub(super) async fn open_owned_dataset_for_branch_write( .ensure_expected_version(&ds, table_key, entry_version)?; } } - Ok((ds, Some(native_active.to_string()))) + Ok((ds, Some(branch.to_string()))) } source_branch => { crate::failpoints::maybe_fail(crate::failpoints::names::FORK_BEFORE_CLASSIFY)?; - // Authority check before forking: re-read the live manifest. If this - // table is already forked on active_branch, a concurrent first-write - // won the race and our snapshot is stale — that is a retryable - // conflict, not an orphan. (A zombie fork is never in the manifest, - // so this only fires for a live concurrent fork.) let live = db.snapshot_for_branch(Some(active_branch)).await?; - if let Some(entry) = live.dataset(table_key) { - if entry.native_dataset_branch.as_deref() == Some(native_active) { - return if occ_enrolled { - Err(OmniError::manifest_read_set_changed( - format!("published_dataset_version:{table_key}"), - Some(entry_version.to_string()), - Some(entry.published_dataset_version.to_string()), - )) - } else { - Err(OmniError::published_dataset_version_mismatch( - table_key, - entry_version, - entry.published_dataset_version, - )) - }; - } + let current = live.dataset(table_key).ok_or_else(|| { + OmniError::manifest_read_set_changed( + format!("dataset_binding:{identity}"), + Some(format!("{identity}:{entry_version}:{source_branch:?}")), + None, + ) + })?; + if current.identity != identity + || current.published_dataset_version != entry_version + || current.native_dataset_branch.as_deref() != source_branch + || live.native_branch() != Some(native_active) + { + return Err(OmniError::manifest_read_set_changed( + format!("dataset_binding:{identity}"), + Some(format!("{identity}:{entry_version}:{source_branch:?}")), + Some(format!( + "{}:{}:{:?}", + current.identity, + current.published_dataset_version, + current.native_dataset_branch, + )), + )); } - // The fork advances Lance state before the manifest publish. The - // caller holds the per-(table, active_branch) write queue from - // before this fork through the publish, so a leftover ref is a - // manifest-unreferenced fork (interrupted prior fork, or - // delete+recreate), not a live in-process fork. The wrapper - // self-heals it (reclaim + re-fork); see - // `Omnigraph::fork_dataset_from_entry_state`. - db.fork_dataset_from_entry_state( - table_key, - identity, - full_path, - source_branch, - entry_version, + let target = crate::branch_names::table_fork_name( native_active, - ) - .await?; + base_manifest_version, + &crate::dst_ids::new_ulid().to_string(), + ); let ds = db - .storage() - .open_dataset_head(full_path, Some(native_active)) + .fork_dataset_from_entry_state( + table_key, + identity, + full_path, + source_branch, + entry_version, + &target, + ) .await?; if op_kind.strict_pre_stage_version_check() { db.storage() .ensure_expected_version(&ds, table_key, entry_version)?; } - Ok((ds, Some(native_active.to_string()))) + Ok((ds, Some(target))) } } } @@ -1375,7 +1370,7 @@ pub(super) async fn fork_dataset_from_entry_state( source_branch: Option<&str>, source_version: u64, active_branch: &str, -) -> Result> { +) -> Result { db.storage() .fork_branch_from_state( full_path, @@ -1387,13 +1382,9 @@ pub(super) async fn fork_dataset_from_entry_state( .await } -/// Classification of a Lance branch ref `B` on table `T` against FRESH manifest -/// authority — the single decision both fork-ref reclaim sites share: the -/// write-path reclaim ([`reclaim_orphaned_fork_and_refork`]) and the cleanup -/// reconciler (`optimize::reconcile_orphaned_branches`). Having one classifier -/// keeps the two destructive sites from drifting (the bug history: each was -/// hardened separately and the other lagged). +/// Classify a table fork against fresh graph publication and recovery authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(test)] pub(crate) enum ForkRefStatus { /// The manifest places `T` on `B` — a legitimate fork. Never destroy. Legitimate, @@ -1408,13 +1399,8 @@ pub(crate) enum ForkRefStatus { Indeterminate, } -/// Classify a fork ref from FRESH manifest authority (bypasses the coordinator -/// cache). MUST be called with the schema-control and per-`(table, branch)` -/// write queues held, so the classification is stable for the caller's -/// critical section. Both reclaim sites map the result to their own action -/// (write path: reclaim vs retryable; cleanup: delete vs skip), but the -/// destroy-only-on-`Orphan` rule is enforced here, once. -pub(crate) async fn classify_fork_ref( +#[cfg(test)] +async fn classify_fork_ref( db: &Omnigraph, _table_key: &str, identity: crate::db::manifest::TableIdentity, @@ -1438,6 +1424,7 @@ pub(crate) async fn classify_fork_ref( /// Use a graph-wide proof captured under the same held schema control gate. /// Recovery pins are checked separately because an unpublished first-touch ref /// may be live even though no graph snapshot references it yet. +#[cfg(test)] pub(crate) async fn classify_fork_ref_with_references( db: &Omnigraph, identity: crate::db::manifest::TableIdentity, @@ -1471,173 +1458,6 @@ pub(crate) async fn classify_fork_ref_with_references( } } -/// Reclaim a manifest-unreferenced fork and re-fork in its place. -/// -/// Reached when `fork_branch_from_state` reports `RefAlreadyExists`. This is a -/// destructive op (it force-deletes a Lance branch ref), so it owns its own -/// safety precondition rather than trusting the caller's: it re-derives, via -/// [`classify_fork_ref`], that no live graph branch references this table on -/// `active_branch`. The caller's earlier proof may have come from the -/// coordinator's *cached* branch snapshot (`resolved_branch_target` returns -/// the cache when the embedded handle is bound to `active_branch`); trusting it could -/// force-delete a fork a concurrent writer just legitimately published. Only -/// once fresh authority confirms the ref is unreferenced does it drop the ref -/// (idempotent `force_delete_branch`) and re-fork, exactly once. -/// -/// If fresh authority shows the table IS on `active_branch` (a legitimate -/// concurrent fork), or a second collision occurs after reclaim (a foreign- -/// process writer recreated the ref — the documented one-winner-CAS gap), it -/// surfaces a retryable conflict; on retry the winner's fork is visible and -/// the no-fork path runs. -/// Drop a ref fresh authority classified `Orphan` (idempotent). The merge -/// pre-arm calls this before any intent exists, so an armed first touch never -/// meets a leftover ref forked at a version its sidecar did not name. -pub(crate) async fn force_delete_orphan_ref( - db: &Omnigraph, - table_key: &str, - full_path: &str, - native: &str, -) -> Result<()> { - crate::failpoints::maybe_fail(crate::failpoints::names::FORK_BEFORE_RECLAIM)?; - db.storage() - .force_delete_branch(full_path, native) - .await - .map_err(|e| { - // Lance's RefConflict prose is not an API contract; a typed variant - // through `force_delete_branch` is the follow-up. - if e.to_string().contains("referenc") { - OmniError::manifest_conflict(format!( - "branch '{native}' cannot reclaim the leftover fork for \ - table '{table_key}' because it has dependent child branches; \ - delete the child branches first" - )) - } else { - e - } - }) -} - -pub(super) async fn reclaim_orphaned_fork_and_refork( - db: &Omnigraph, - table_key: &str, - identity: crate::db::manifest::TableIdentity, - full_path: &str, - source_branch: Option<&str>, - source_version: u64, - active_branch: &str, - current_operation_id: Option<&str>, -) -> Result { - // The immutable identity and physical target are one authority fact. Keep - // them coupled here as a final destructive-operation guard: an old - // incarnation must never authorize deleting a ref on a replacement table's - // path, even if both lifetimes reused the same public alias. - let canonical_path = crate::db::manifest::table_path_for_identity(table_key, identity)?; - let canonical_full_path = db.storage().dataset_uri(&canonical_path); - if full_path != canonical_full_path { - return Err(OmniError::manifest_read_set_changed( - format!("fork_target_dataset_path:{identity}"), - Some(canonical_full_path), - Some(full_path.to_string()), - )); - } - - // A v9 mutation/load sidecar (`protocol_v3` payload) is written before its deferred fork. A - // manifest-unreferenced ref claimed by another pending operation is live, - // not an orphan: never force-delete it. Excluding our own operation lets a - // writer reclaim a genuinely stale pre-existing ref after its own intent is - // durable. A sidecar-list failure is indeterminate and therefore loud. - // `active_branch` is the native fork ref; sidecars key their `branch` by - // the logical name and their pins by the native ref. - let logical_active = crate::branch_names::logical_branch_name(active_branch); - let sidecars = crate::db::manifest::list_sidecars(db.root_uri(), db.storage_adapter()).await?; - if let Some(owner) = sidecars.iter().find(|sidecar| { - Some(sidecar.operation_id.as_str()) != current_operation_id - && sidecar.tables.iter().any(|pin| { - pin.identity == identity && pin.table_branch.as_deref() == Some(active_branch) - }) - }) { - return Err(OmniError::manifest_read_set_changed( - format!("fork_intent:{active_branch}:{table_key}"), - None, - Some(owner.operation_id.clone()), - )); - } - - // Self-validate against FRESH authority before destroying anything. Only an - // Orphan is reclaimable; a Legitimate status (a concurrent writer published - // a real fork despite the caller's possibly-cached proof) or an - // Indeterminate one (transient read) surfaces a retryable conflict rather - // than stranding the manifest at a version the recreated ref won't have. - match classify_fork_ref(db, table_key, identity, active_branch, current_operation_id).await { - ForkRefStatus::Orphan => {} - ForkRefStatus::Borrowed => { - return Err(crate::db::manifest::detached_native_lineage_error( - table_key, - active_branch, - )); - } - ForkRefStatus::Legitimate => { - let actual = db - .fresh_snapshot_for_branch(Some(logical_active)) - .await - .ok() - .and_then(|s| { - s.datasets() - .find(|entry| entry.identity == identity) - .map(|entry| entry.published_dataset_version) - }) - .unwrap_or(source_version); - if current_operation_id.is_some() { - return Err(OmniError::manifest_read_set_changed( - format!("published_dataset_version:{table_key}"), - Some(source_version.to_string()), - Some(actual.to_string()), - )); - } - return Err(OmniError::published_dataset_version_mismatch( - table_key, - source_version, - actual, - )); - } - ForkRefStatus::Indeterminate => { - return Err(OmniError::manifest_conflict(format!( - "could not verify whether branch '{active_branch}' still owns an orphaned \ - fork for table '{table_key}' because fresh manifest authority was \ - unavailable; refresh and retry" - ))); - } - } - - force_delete_orphan_ref(db, table_key, full_path, active_branch).await?; - - match fork_dataset_from_entry_state( - db, - table_key, - full_path, - source_branch, - source_version, - active_branch, - ) - .await? - { - crate::storage_layer::ForkOutcome::Created(ds) => Ok(ds), - crate::storage_layer::ForkOutcome::RefAlreadyExists => { - let live = db.fresh_snapshot_for_branch(Some(logical_active)).await?; - let actual = live - .datasets() - .find(|entry| entry.identity == identity) - .map(|entry| entry.published_dataset_version) - .unwrap_or(source_version); - Err(OmniError::published_dataset_version_mismatch( - table_key, - source_version, - actual, - )) - } - } -} - pub(super) async fn reopen_for_mutation( db: &Omnigraph, table_key: &str, @@ -1801,7 +1621,9 @@ async fn prepare_updates_for_commit( let state = db.storage().table_state(&full_path, &ds).await?; prepared_update.published_dataset_version = state.version; prepared_update.entity_count = state.row_count; - prepared_update.version_metadata = state.version_metadata; + prepared_update.version_metadata = state + .version_metadata + .with_table_fork_owner(prepared_update.version_metadata.table_fork_owner()); } prepared.push(prepared_update); @@ -2023,12 +1845,7 @@ pub(super) async fn invalidate_graph_index(db: &Omnigraph) { #[cfg(test)] mod classify_fork_ref_tests { - //! Direct coverage of [`classify_fork_ref`] — the single fresh-authority - //! decision both fork-ref reclaim sites (write-path reclaim + cleanup - //! reconciler) route through. Pins each deterministic status so reverting - //! the fresh-authority logic at either site fails here. (The `Indeterminate` - //! arm needs an injected transient read and is covered under the - //! `failpoints` suite.) + //! Physical fork classification and stable table-identity guards. use super::*; use crate::db::Omnigraph; use crate::loader::LoadMode; @@ -2067,7 +1884,19 @@ mod classify_fork_ref_tests { let company_identity = feature_snapshot.dataset("node:Company").unwrap().identity; let person_identity = feature_snapshot.dataset("node:Person").unwrap().identity; assert_eq!( - classify_fork_ref(&db, "node:Company", company_identity, &feature_native, None,).await, + classify_fork_ref( + &db, + "node:Company", + company_identity, + feature_snapshot + .dataset("node:Company") + .unwrap() + .native_dataset_branch + .as_deref() + .unwrap(), + None, + ) + .await, ForkRefStatus::Legitimate, "a manifest-placed fork must classify as Legitimate (never destroyed)" ); @@ -2148,7 +1977,13 @@ mod classify_fork_ref_tests { .await .unwrap(); - let feature_native = db.native_branch_for("feature").await.unwrap(); + let feature_snapshot = db.snapshot_for_branch(Some("feature")).await.unwrap(); + let feature_native = feature_snapshot + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); assert_eq!( classify_fork_ref(&db, "node:Person", new_identity, &feature_native, None).await, ForkRefStatus::Legitimate @@ -2166,18 +2001,16 @@ mod classify_fork_ref_tests { .await .unwrap(); let before_identifier = db.storage().branch_identifier(&before).await.unwrap(); - reclaim_orphaned_fork_and_refork( - &db, + db.fork_dataset_from_entry_state( "node:Person", old_identity, &full_path, None, new_entry.published_dataset_version, &feature_native, - None, ) .await - .expect_err("a stale identity must not authorize deletion on the replacement path"); + .expect_err("a stale identity must not authorize effects on the replacement path"); let after = db .storage() .open_dataset_head(&full_path, Some(&feature_native)) diff --git a/crates/omnigraph/src/db/recovery_audit.rs b/crates/omnigraph/src/db/recovery_audit.rs index f46040ea..137a6cbf 100644 --- a/crates/omnigraph/src/db/recovery_audit.rs +++ b/crates/omnigraph/src/db/recovery_audit.rs @@ -200,6 +200,9 @@ async fn create_recoveries_dataset(root_uri: &str) -> Result { session: Some(control_session), ..Default::default() }; + let params = crate::storage_layer::lance_clone::write_params(&uri, params) + .await + .map_err(OmniError::storage)?; match Dataset::write(reader, &uri as &str, Some(params)).await { Ok(dataset) => Ok(dataset), // Create-or-open idempotency — match the typed `DatasetAlreadyExists` diff --git a/crates/omnigraph/src/exec/merge.rs b/crates/omnigraph/src/exec/merge.rs index 43383b55..c04e3e89 100644 --- a/crates/omnigraph/src/exec/merge.rs +++ b/crates/omnigraph/src/exec/merge.rs @@ -23,17 +23,10 @@ const PURE_INSERT_HISTORY_READ_CONCURRENCY: usize = 8; #[derive(Debug)] enum CandidateTableState { - /// Adopt the source's table state via a pointer switch or a branch fork — - /// no data HEAD advance, so nothing to pin for recovery. `validation_delta` - /// carries the source-vs-target row delta (added/changed/deleted) for the - /// evaluator ONLY — the publish is still a pointer/fork — so a pointer-adopt - /// whose source diverged is still validated (RI/uniqueness/cardinality) - /// against the merged state instead of being silently published. `None` when - /// the source matched the target (nothing to validate). Decoupling the - /// validation delta from the publish mechanism keeps the publish O(1) while - /// closing the unvalidated-adopt gap. + /// Publish the source endpoint without advancing table HEAD. + /// `validation_delta` holds changed rows for constraint checks; `None` means equal rows. AdoptSourceState { - validation_delta: Option, + validation_delta: Option, }, /// The target table still equals the merge base and Lance's complete source /// transaction interval proves that every logical change was an exact-id @@ -49,6 +42,12 @@ enum CandidateTableState { RewriteMerged(StagedMergeResult), } +#[derive(Debug)] +enum AdoptValidation { + RowDelta(Box), + PureInserts(Box), +} + /// An existing target ref opened and verified against the target manifest pin /// under branch merge's final schema -> branch -> table gate envelope. /// @@ -94,12 +93,18 @@ async fn prepare_existing_merge_target( let entry = txn.base.dataset(table_key).ok_or_else(|| { OmniError::manifest_internal(format!("captured merge target lacks '{table_key}'")) })?; - let native = captured_merge_target_ref(txn)?; - if native.is_some() && entry.native_dataset_branch.as_deref() != native { + let owner = captured_merge_target_ref(txn)?; + if owner.is_some_and(|owner| { + !entry + .native_dataset_branch + .as_deref() + .is_some_and(|native| entry.version_metadata.is_table_fork_of(native, owner)) + }) { return Err(OmniError::manifest_internal(format!( "merge target '{table_key}' requires a recovery-owned first touch" ))); } + let native = entry.native_dataset_branch.as_deref(); let full_path = db.storage().dataset_uri(&entry.dataset_path); let current = db.storage().open_dataset_head(&full_path, native).await?; Ok(PreparedExistingMergeTarget { @@ -109,31 +114,37 @@ async fn prepare_existing_merge_target( }) } -/// Create a first-touch target only inside the armed merge effect phase. The -/// captured inherited entry selects the source; the captured target ref and -/// current sidecar identity fence reclaim of any leftover native fork. +/// Create the exact first-touch ref persisted by the armed merge intent. +/// The captured inherited entry fixes its source ref and version. async fn open_first_touch_merge_target( db: &Omnigraph, txn: &WriteTxn, table_key: &str, recovery_operation_id: Option<&str>, + first_touch_branch: Option<&str>, ) -> Result<(SnapshotHandle, String, Option)> { let operation_id = recovery_operation_id.ok_or_else(|| { OmniError::manifest_internal("first-touch merge target has no armed recovery intent") })?; - let native = captured_merge_target_ref(txn)?.ok_or_else(|| { + let owner = captured_merge_target_ref(txn)?.ok_or_else(|| { OmniError::manifest_internal("first-touch merge target must be a named branch") })?; + let native = first_touch_branch.ok_or_else(|| { + OmniError::manifest_internal("first-touch merge target lacks its armed native ref") + })?; let entry = txn.base.dataset(table_key).ok_or_else(|| { OmniError::manifest_internal(format!("captured merge target lacks '{table_key}'")) })?; - if entry.native_dataset_branch.as_deref() == Some(native) { + if entry + .native_dataset_branch + .as_deref() + .is_some_and(|fork| entry.version_metadata.is_table_fork_of(fork, owner)) + { return Err(OmniError::manifest_internal(format!( "first-touch merge target '{table_key}' already owns its captured table" ))); } let full_path = db.storage().dataset_uri(&entry.dataset_path); - crate::failpoints::maybe_fail(crate::failpoints::names::FORK_BEFORE_CLASSIFY)?; let current = db .fork_dataset_from_entry_state_under_intent( table_key, @@ -1226,6 +1237,64 @@ fn sanitize_table_key(table_key: &str) -> String { .collect() } +/// Partition history at native fork boundaries, whose Clone transaction replaces +/// the parent's transaction at the same numeric version. Parent names locate +/// records; exact identifier prefixes establish their authority. +async fn proven_insert_history_segments( + source: &Dataset, + source_identifier: &lance::dataset::refs::BranchIdentifier, + begin_version: u64, +) -> Option)>> { + let mut dataset = source.clone(); + let mut identifier = source_identifier.clone(); + let mut end_version = source.version().version; + let mut segments = Vec::new(); + let mut crossed_boundaries = 0_u64; + loop { + let fork_version = identifier.version_mapping.last().map_or(0, |entry| entry.0); + if fork_version > end_version { + return None; + } + if fork_version <= begin_version { + if begin_version < end_version { + segments.push((dataset, (begin_version + 1)..=end_version)); + } + segments.reverse(); + return Some(segments); + } + crossed_boundaries += 1; + if crossed_boundaries > PURE_INSERT_HISTORY_MAX_VERSIONS { + return None; + } + let branch = dataset.manifest.branch.as_deref()?; + let contents = dataset.branches().get(branch).await.ok()?; + if contents.identifier != identifier || contents.parent_version != fork_version { + return None; + } + let parent = dataset + .checkout_version(lance::dataset::refs::Ref::Version( + contents.parent_branch.clone(), + Some(fork_version), + )) + .await + .ok()?; + let parent_identifier = parent.branch_identifier().await.ok()?; + if parent.manifest.branch != contents.parent_branch + || parent.version().version != fork_version + || parent_identifier.version_mapping.as_slice() + != &identifier.version_mapping[..identifier.version_mapping.len() - 1] + { + return None; + } + if fork_version < end_version { + segments.push((dataset, (fork_version + 1)..=end_version)); + } + dataset = parent; + identifier = parent_identifier; + end_version = fork_version; + } +} + /// Try to prove that `(base, source]` contains only exact-id fenced inserts. /// /// Missing/cleaned transaction files and every unfamiliar operation are a @@ -1324,21 +1393,24 @@ async fn try_proven_pure_insert_history( else { return Ok(None); }; - // Lance 11 reads each manifest/transaction transiently without constructing - // historical Datasets or populating their index/metadata caches. Keep only - // a bounded ordered window of records, rather than collecting the interval. - // The pinned endpoint incarnation checks above still own lineage proof. - let mut transactions = futures::stream::iter( - (base_entry.published_dataset_version + 1)..=source_entry.published_dataset_version, + let Some(segments) = proven_insert_history_segments( + &source, + &source_identifier, + base_entry.published_dataset_version, ) - .map(|version| { - let source = &source; - async move { + .await + else { + return Ok(None); + }; + let mut transactions = + futures::stream::iter(segments.into_iter().flat_map(|(dataset, versions)| { + versions.map(move |version| (dataset.clone(), version)) + })) + .map(|(dataset, version)| async move { crate::instrumentation::record_proven_insert_history_read(); - (version, source.read_version_transaction(version).await) - } - }) - .buffered(PURE_INSERT_HISTORY_READ_CONCURRENCY); + (version, dataset.read_version_transaction(version).await) + }) + .buffered(PURE_INSERT_HISTORY_READ_CONCURRENCY); let mut proven_inserted_rows = 0_u64; while let Some((version, record)) = transactions.next().await { let record = match record { @@ -3521,14 +3593,24 @@ fn retain_deleted_ids_for_validation( Ok(()) } -/// Build the per-table [`ChangeSet`](crate::validate::ChangeSet) for a merge from -/// the classified candidates — the new/changed rows (from the staged deltas) and -/// removed ids the validator evaluates, instead of re-scanning whole tables. -/// `AdoptSourceState` is published as a pointer/fork but still carries a -/// `validation_delta` (the source-vs-target rows) when its source diverged, so -/// it is validated like `AdoptWithDelta`; only an empty-delta adopt is skipped. -/// `AdoptPureInserts` projects the proven source interval directly, avoiding a -/// temporary delta table while retaining the same constraint evaluation. +async fn scan_adopt_delta_for_validation( + db: &Omnigraph, + delta: &AdoptDelta, + projection: &[&str], + budget: &mut MergeValidationBudget, + change: &mut crate::validate::TableChange, +) -> Result<()> { + if let Some(table) = &delta.inserts { + scan_staged_for_validation(db, table, projection, budget, &mut change.added).await?; + } + if let Some(table) = &delta.upserts { + scan_staged_for_validation(db, table, projection, budget, &mut change.changed).await?; + } + retain_deleted_ids_for_validation(&delta.deleted_ids, budget, &mut change.deleted_ids)?; + Ok(()) +} + +/// Build the validator's per-table changes from candidate deltas and proven inserts. async fn build_merge_changeset( db: &Omnigraph, catalog: &Catalog, @@ -3554,42 +3636,43 @@ async fn build_merge_changeset( let projection: Vec<&str> = projection.iter().map(String::as_str).collect(); let mut change = crate::validate::TableChange::default(); match candidate { - // Pointer/fork adopt whose source matched the target: nothing to - // validate. A pointer/fork adopt whose source diverged carries a - // `validation_delta` and is validated exactly like `AdoptWithDelta` - // (only the publish differs — pointer vs HEAD-advancing). CandidateTableState::AdoptSourceState { validation_delta: None, } => continue, CandidateTableState::AdoptSourceState { - validation_delta: Some(delta), + validation_delta: Some(AdoptValidation::RowDelta(delta)), + } => { + scan_adopt_delta_for_validation( + db, + delta, + &projection, + &mut validation_budget, + &mut change, + ) + .await?; } - | CandidateTableState::AdoptWithDelta(delta) => { - if let Some(table) = &delta.inserts { - scan_staged_for_validation( - db, - table, - &projection, - &mut validation_budget, - &mut change.added, - ) - .await?; - } - if let Some(table) = &delta.upserts { - scan_staged_for_validation( - db, - table, - &projection, - &mut validation_budget, - &mut change.changed, - ) - .await?; - } - retain_deleted_ids_for_validation( - &delta.deleted_ids, + CandidateTableState::AdoptWithDelta(delta) => { + scan_adopt_delta_for_validation( + db, + delta, + &projection, &mut validation_budget, - &mut change.deleted_ids, - )?; + &mut change, + ) + .await?; + } + CandidateTableState::AdoptSourceState { + validation_delta: Some(AdoptValidation::PureInserts(proven)), + } => { + scan_proven_pure_inserts_for_validation( + db, + table_key, + proven, + &projection, + &mut validation_budget, + &mut change.added, + ) + .await?; } CandidateTableState::AdoptPureInserts(proven) => { scan_proven_pure_inserts_for_validation( @@ -3853,23 +3936,13 @@ fn row_id_at(batch: &RecordBatch, row: usize) -> Result { fn adopt_advances_head( target_active: Option<&str>, source_entry: &crate::db::DatasetEntry, - target_entry: Option<&crate::db::DatasetEntry>, ) -> bool { - match (target_active, source_entry.native_dataset_branch.as_deref()) { - // Source on a branch, target on main — delta applied onto main's lineage. - (None, Some(_)) => true, - // Both on branches, target owns this table — delta applied onto it. - (Some(target_branch), Some(_)) => { - target_entry.and_then(|entry| entry.native_dataset_branch.as_deref()) - == Some(target_branch) - } - _ => false, - } + target_active.is_none() && source_entry.native_dataset_branch.is_some() } /// Classify a table whose target equals base: a proven insertion-only descendant /// is `AdoptPureInserts`, any other HEAD-advancing delta is `AdoptWithDelta`, a -/// pointer switch or fork is `AdoptSourceState` (RFC 0062 orders the switch). +/// pointer switch is `AdoptSourceState` (RFC 0062 orders the switch). async fn classify_adopt( target_db: &Omnigraph, catalog: &Catalog, @@ -3893,11 +3966,7 @@ async fn classify_adopt( Some(source_entry.identity), target_entry.map(|entry| entry.identity), )?; - let advances_head = adopt_advances_head(target_active, source_entry, target_entry); - // A complete Lance transaction interval can prove the common all-new-row - // case without re-scanning and sorting base + source. The proof is accepted - // only for a HEAD-advancing publish; pointer/fork adoption still uses the - // general delta as its validation input. + let advances_head = adopt_advances_head(target_active, source_entry); if advances_head && let Some(proven) = try_proven_pure_insert_adopt(target_db, table_key, base_snapshot, source_snapshot) @@ -3905,6 +3974,14 @@ async fn classify_adopt( { return Ok(Some(CandidateTableState::AdoptPureInserts(proven))); } + if target_active.is_some() + && let Some(proven) = + try_proven_pure_insert_history(table_key, base_snapshot, source_snapshot).await? + { + return Ok(Some(CandidateTableState::AdoptSourceState { + validation_delta: Some(AdoptValidation::PureInserts(Box::new(proven))), + })); + } let candidate = classify_general_adopt( target_db, @@ -3926,10 +4003,8 @@ async fn classify_adopt( )) } -/// Classify the general adopt route after the pure-insert proof was either -/// unavailable or exceeded its recovery-plan ceiling. The caller has already -/// established table identity and whether publication advances the target -/// data HEAD. +/// Build an adoption candidate for a target that still matches the merge base. +/// The caller establishes table identity and whether publication advances HEAD. async fn classify_general_adopt( target_db: &Omnigraph, catalog: &Catalog, @@ -3939,14 +4014,6 @@ async fn classify_general_adopt( advances_head: bool, external_preflight: &crate::table_store::ExternalBlobPreflight, ) -> Result { - // Compute the source-vs-target delta for the general route — it is the validation - // input the evaluator needs, independent of how the table is published. - // (`classify_adopt` is only reached when base == target, so the - // base-vs-source delta equals the target-vs-source delta.) A HEAD-advancing - // publish consumes it as the write payload (`AdoptWithDelta`); a pointer/fork - // publish ignores it and only validates it (`AdoptSourceState`), so a - // pointer-adopt whose source diverged is still checked for - // RI/uniqueness/cardinality against the merged state. let validation_delta = compute_adopt_delta( target_db, table_key, @@ -3959,7 +4026,10 @@ async fn classify_general_adopt( .await?; match (advances_head, validation_delta) { (true, Some(delta)) => Ok(CandidateTableState::AdoptWithDelta(delta)), - (_, validation_delta) => Ok(CandidateTableState::AdoptSourceState { validation_delta }), + (_, validation_delta) => Ok(CandidateTableState::AdoptSourceState { + validation_delta: validation_delta + .map(|delta| AdoptValidation::RowDelta(Box::new(delta))), + }), } } @@ -3973,16 +4043,10 @@ enum AdoptPublish { Nothing, /// A pointer switch onto a lineage the target can already read. Pointer(crate::db::DatasetUpdate), - /// The target holds no ref for this table yet; publication forks one. The - /// only arm that touches storage. - Fork { - source_branch: String, - target_branch: String, - }, } -/// Plan what adopting the source's table state publishes, without an effect; -/// reaching a branch-bearing arm means the delta was empty. +/// Plan the exact source endpoint for a named target. Main retains its lineage +/// after an empty source delta. fn plan_adopted_source_state( target_active: Option<&str>, source_entry: &crate::db::DatasetEntry, @@ -3991,13 +4055,11 @@ fn plan_adopted_source_state( ) -> AdoptPublish { let identity = source_entry.identity; let planned = match (target_active, source_entry.native_dataset_branch.as_deref()) { - // Source on main — pointer switch to its version. The target reads the - // same lineage whether it sits on main or on a branch. - (None, None) | (Some(_), None) => crate::db::DatasetUpdate { + (None, None) | (Some(_), _) => crate::db::DatasetUpdate { identity, type_key: table_key.to_string(), published_dataset_version: source_entry.published_dataset_version, - native_dataset_branch: None, + native_dataset_branch: source_entry.native_dataset_branch.clone(), entity_count: source_entry.entity_count, version_metadata: source_entry.version_metadata.clone(), }, @@ -4015,26 +4077,6 @@ fn plan_adopted_source_state( .map(|entry| entry.version_metadata.clone()) .unwrap_or_else(|| source_entry.version_metadata.clone()), }, - (Some(target_branch), Some(source_branch)) => { - let Some(owned) = target_entry - .filter(|entry| entry.native_dataset_branch.as_deref() == Some(target_branch)) - else { - // A fork registers a ref the target lacks, so it is never a - // no-op. - return AdoptPublish::Fork { - source_branch: source_branch.to_string(), - target_branch: target_branch.to_string(), - }; - }; - crate::db::DatasetUpdate { - identity, - type_key: table_key.to_string(), - published_dataset_version: owned.published_dataset_version, - native_dataset_branch: Some(target_branch.to_string()), - entity_count: source_entry.entity_count, - version_metadata: owned.version_metadata.clone(), - } - } }; if target_entry.is_some_and(|current| reregisters_current_entry(&planned, current)) { @@ -4144,14 +4186,18 @@ mod adopt_plan_tests { } #[test] - fn target_owned_branch_table_with_equal_rows_plans_nothing() { + fn target_owned_branch_table_adopts_the_exact_source_endpoint() { let target = entry(5, Some("target"), 3, "manifest-v5"); - let source = entry(7, Some("source"), 3, "manifest-v7"); - assert!(adopt_advances_head(Some("target"), &source, Some(&target))); - assert!(matches!( - plan_adopted_source_state(Some("target"), &source, Some(&target), "edge:Knows"), - AdoptPublish::Nothing - )); + for source_version in [3, 5, 7] { + let source = entry(source_version, Some("source"), 3, "manifest-source"); + assert!(!adopt_advances_head(Some("target"), &source)); + assert!(matches!( + plan_adopted_source_state(Some("target"), &source, Some(&target), "edge:Knows"), + AdoptPublish::Pointer(update) if update.native_dataset_branch.as_deref() == Some("source") + && update.published_dataset_version == source_version + && update.version_metadata == source.version_metadata + )); + } } /// Source on main into a branch that owns the table: a pointer switch onto @@ -4161,7 +4207,7 @@ mod adopt_plan_tests { let target = entry(5, Some("target"), 3, "manifest-v5"); for source_version in [3, 5, 7] { let source = entry(source_version, None, 3, "manifest-main"); - assert!(!adopt_advances_head(Some("target"), &source, Some(&target))); + assert!(!adopt_advances_head(Some("target"), &source)); assert!(matches!( plan_adopted_source_state(Some("target"), &source, Some(&target), "edge:Knows"), AdoptPublish::Pointer(update) if update.native_dataset_branch.is_none() @@ -4224,29 +4270,24 @@ mod adopt_plan_tests { )); } - /// A target branch that does not own the table has no ref to compare, so - /// the publish forks one. This arm can never be a no-op. #[test] - fn unowned_target_branch_plans_a_fork() { + fn unowned_target_branch_plans_a_source_pointer() { let target = entry(2, None, 3, "manifest-v2"); let source = entry(4, Some("source"), 3, "manifest-v4"); assert!(matches!( plan_adopted_source_state(Some("target"), &source, Some(&target), "edge:Knows"), - AdoptPublish::Fork { .. } + AdoptPublish::Pointer(update) if update.native_dataset_branch.as_deref() == Some("source") + && update.published_dataset_version == 4 )); } } -/// Adopt the source's table state without a row delta, as planned by -/// [`plan_adopted_source_state`]: a pointer switch, or a fork under -/// `recovery_operation_id` so its reclaim never mistakes the merge's own pin. -async fn publish_adopted_source_state( - target_db: &Omnigraph, +/// Publish the source's pinned endpoint after validating its candidate delta. +fn publish_adopted_source_state( source_snapshot: &Snapshot, target_snapshot: &Snapshot, table_key: &str, target_active: Option<&str>, - recovery_operation_id: Option<&str>, ) -> Result { let source_entry = source_snapshot .dataset(table_key) @@ -4261,38 +4302,6 @@ async fn publish_adopted_source_state( match plan_adopted_source_state(target_active, source_entry, target_entry, table_key) { AdoptPublish::Pointer(update) => Ok(update), - AdoptPublish::Fork { - source_branch, - target_branch, - } => { - let operation_id = recovery_operation_id.ok_or_else(|| { - OmniError::manifest_internal("first-touch adopt fork has no armed recovery intent") - })?; - let full_path = format!("{}/{}", target_db.uri(), source_entry.dataset_path); - let ds = target_db - .fork_dataset_from_entry_state_under_intent( - table_key, - source_entry.identity, - &full_path, - Some(&source_branch), - source_entry.published_dataset_version, - &target_branch, - Some(operation_id), - ) - .await?; - let state = target_db.storage().table_state(&full_path, &ds).await?; - Ok(crate::db::DatasetUpdate { - identity: source_entry.identity, - type_key: table_key.to_string(), - published_dataset_version: state.version, - native_dataset_branch: Some(target_branch), - entity_count: state.row_count, - version_metadata: state.version_metadata, - }) - } - // Classification drops a table whose adopt publishes nothing, so this - // arm means the candidate set and the plan disagree — an engine bug, - // not a caller error. AdoptPublish::Nothing => Err(OmniError::manifest_internal(format!( "branch merge table '{table_key}' publishes nothing and must not be a merge candidate" ))), @@ -4629,6 +4638,7 @@ async fn publish_rewritten_merge_table( target_db: &Omnigraph, target_txn: &WriteTxn, recovery_operation_id: Option<&str>, + first_touch_branch: Option<&str>, table_key: &str, identity: crate::db::manifest::TableIdentity, staged: &StagedMergeResult, @@ -4645,8 +4655,14 @@ async fn publish_rewritten_merge_table( let (mut current_ds, full_path, table_branch) = match prepared_target { Some(prepared) => prepared.into_parts(), None => { - open_first_touch_merge_target(target_db, target_txn, table_key, recovery_operation_id) - .await? + open_first_touch_merge_target( + target_db, + target_txn, + table_key, + recovery_operation_id, + first_touch_branch, + ) + .await? } }; @@ -4736,7 +4752,9 @@ async fn publish_rewritten_merge_table( published_dataset_version: final_state.version, native_dataset_branch: table_branch, entity_count: final_state.row_count, - version_metadata: final_state.version_metadata, + version_metadata: final_state + .version_metadata + .with_table_fork_owner(captured_merge_target_ref(target_txn)?), }) } @@ -5002,6 +5020,7 @@ async fn publish_adopted_delta( target_db: &Omnigraph, target_txn: &WriteTxn, recovery_operation_id: Option<&str>, + first_touch_branch: Option<&str>, table_key: &str, identity: crate::db::manifest::TableIdentity, delta: &AdoptDelta, @@ -5013,8 +5032,14 @@ async fn publish_adopted_delta( let (mut current_ds, full_path, table_branch) = match prepared_target { Some(prepared) => prepared.into_parts(), None => { - open_first_touch_merge_target(target_db, target_txn, table_key, recovery_operation_id) - .await? + open_first_touch_merge_target( + target_db, + target_txn, + table_key, + recovery_operation_id, + first_touch_branch, + ) + .await? } }; @@ -5115,7 +5140,9 @@ async fn publish_adopted_delta( published_dataset_version: final_state.version, native_dataset_branch: table_branch, entity_count: final_state.row_count, - version_metadata: final_state.version_metadata, + version_metadata: final_state + .version_metadata + .with_table_fork_owner(captured_merge_target_ref(target_txn)?), }) } @@ -5485,11 +5512,6 @@ impl Omnigraph { let mut blob_pure_insert_histories: HashMap = HashMap::new(); let materializer = self.blob_materializer(); - // Classify scalar tables once before any external source I/O. Blob - // tables get a descriptor-only first pass so every row-writing managed - // value and exact external range shares one operation budget. Pointer - // and fork adoption write no row, so their descriptors require neither - // policy approval nor source I/O. for table_key in &ordered_table_keys { let base_entry = base_snapshot.dataset(table_key); let source_entry = source_snapshot.dataset(table_key); @@ -5555,7 +5577,7 @@ impl Omnigraph { let Some(source_entry) = source_entry else { continue; }; - if !adopt_advances_head(target_active.as_deref(), source_entry, target_entry) { + if !adopt_advances_head(target_active.as_deref(), source_entry) { continue; } blob_adopt_proof_attempted.insert(table_key.clone()); @@ -5653,7 +5675,7 @@ impl Omnigraph { if same_manifest_state(base_entry, target_entry) { let candidate = if blob_adopt_proof_attempted.contains(table_key) { let advances_head = source_entry.is_some_and(|source_entry| { - adopt_advances_head(target_active.as_deref(), source_entry, target_entry) + adopt_advances_head(target_active.as_deref(), source_entry) }); if !advances_head { return Err(OmniError::manifest_internal(format!( @@ -5888,7 +5910,14 @@ impl Omnigraph { let mut __dst_cand: Vec<_> = candidates.iter().collect(); __dst_cand.sort_by(|a, b| a.0.cmp(b.0)); for (table_key, candidate) in __dst_cand { - if let CandidateTableState::AdoptPureInserts(proven) = candidate { + let proven = match candidate { + CandidateTableState::AdoptPureInserts(proven) => Some(proven), + CandidateTableState::AdoptSourceState { + validation_delta: Some(AdoptValidation::PureInserts(proven)), + } => Some(proven.as_ref()), + _ => None, + }; + if let Some(proven) = proven { revalidate_proven_pure_insert_source( self, table_key, @@ -5938,7 +5967,7 @@ impl Omnigraph { let mut recovery_pins = Vec::new(); let mut recovery_effects = Vec::new(); let mut delta_slots = Vec::new(); - let mut first_touch_effects = HashSet::new(); + let mut first_touch_effects = HashMap::new(); let mut planned_transactions_by_table = HashMap::new(); let mut prepared_existing_targets = HashMap::new(); for table_key in &ordered_table_keys { @@ -5965,14 +5994,33 @@ impl Omnigraph { let planned_output_branch = match candidate { CandidateTableState::RewriteMerged(_) | CandidateTableState::AdoptWithDelta(_) - | CandidateTableState::AdoptPureInserts(_) => target_active.clone(), + | CandidateTableState::AdoptPureInserts(_) => { + target_active.as_deref().map(|owner| { + match target_entry.filter(|entry| { + entry + .native_dataset_branch + .as_deref() + .is_some_and(|native| { + entry.version_metadata.is_table_fork_of(native, owner) + }) + }) { + Some(entry) => entry + .native_dataset_branch + .clone() + .expect("owned named ref"), + _ => crate::branch_names::table_fork_name( + owner, + target_snapshot.graph_manifest_version(), + &merge_lineage.graph_commit_id, + ), + } + }) + } CandidateTableState::AdoptSourceState { .. } => { - match ( - target_active.as_deref(), - source_entry.native_dataset_branch.as_deref(), - ) { - (Some(target), Some(_)) => Some(target.to_string()), - _ => None, + if target_active.is_some() { + source_entry.native_dataset_branch.clone() + } else { + None } } }; @@ -5980,7 +6028,7 @@ impl Omnigraph { identity, table_key: table_key.clone(), expected_version, - table_branch: planned_output_branch, + table_branch: planned_output_branch.clone(), confirmed: None, }); @@ -5996,7 +6044,14 @@ impl Omnigraph { })?; let source_fork_version = target_active .as_deref() - .filter(|target| entry.native_dataset_branch.as_deref() != Some(*target)) + .filter(|owner| { + !entry + .native_dataset_branch + .as_deref() + .is_some_and(|native| { + entry.version_metadata.is_table_fork_of(native, owner) + }) + }) .map(|_| entry.published_dataset_version); if source_fork_version.is_some() && matches!(candidate, CandidateTableState::AdoptPureInserts(_)) @@ -6006,7 +6061,14 @@ impl Omnigraph { ))); } if source_fork_version.is_some() { - first_touch_effects.insert(table_key.clone()); + first_touch_effects.insert( + table_key.clone(), + planned_output_branch.clone().ok_or_else(|| { + OmniError::manifest_internal( + "first-touch merge has no planned native ref", + ) + })?, + ); } else { // Existing-ref effects must prove that the physical // baseline still equals the captured manifest pin @@ -6029,7 +6091,8 @@ impl Omnigraph { expected_version, post_commit_pin: expected_version + 1, confirmed_version: None, - table_branch: target_active.clone(), + table_branch: planned_output_branch, + table_fork_owner: target_active.clone(), }); recovery_effects.push(crate::db::manifest::RecoveryBranchMergeEffect { identity, @@ -6042,35 +6105,7 @@ impl Omnigraph { }, }); } - CandidateTableState::AdoptSourceState { .. } => { - let Some(target) = target_active.as_deref() else { - continue; - }; - let creates_target_ref = source_entry.native_dataset_branch.is_some() - && target_entry.and_then(|entry| entry.native_dataset_branch.as_deref()) - != Some(target); - if !creates_target_ref { - continue; - } - first_touch_effects.insert(table_key.clone()); - recovery_pins.push(crate::db::manifest::SidecarTablePin { - identity, - table_key: table_key.clone(), - table_path: self.storage().dataset_uri(&source_entry.dataset_path), - expected_version, - post_commit_pin: source_entry.published_dataset_version, - confirmed_version: None, - table_branch: Some(target.to_string()), - }); - recovery_effects.push(crate::db::manifest::RecoveryBranchMergeEffect { - identity, - table_key: table_key.clone(), - kind: crate::db::manifest::RecoveryBranchMergeEffectKind::RefOnlyFork { - source_version: source_entry.published_dataset_version, - confirmed_branch_identifier: None, - }, - }); - } + CandidateTableState::AdoptSourceState { .. } => {} } } @@ -6108,62 +6143,6 @@ impl Omnigraph { continue; } } - if !first_touch_effects.is_empty() { - let references = Box::pin( - ManifestCoordinator::native_fork_references_under_control_gates( - self.root_uri(), - &self.control_session(), - ), - ) - .await?; - let native = target_active.as_deref().ok_or_else(|| { - OmniError::manifest_internal("first-touch merge requires a named target") - })?; - for table_key in &first_touch_effects { - let entry = target_snapshot - .dataset(table_key) - .or_else(|| source_snapshot.dataset(table_key)) - .ok_or_else(|| { - OmniError::manifest_internal("first-touch merge table is missing") - })?; - if references.contains(entry.identity, native) { - return Err(crate::db::manifest::detached_native_lineage_error( - table_key, native, - )); - } - let inherited = self.storage().open_snapshot_at_entry(entry).await?; - let branches = - crate::branch_control::list_branch_contents(inherited.dataset()).await?; - if !branches.contains_key(native) { - continue; - } - match crate::db::classify_fork_ref_with_references( - self, - entry.identity, - native, - None, - &references, - ) - .await - { - crate::db::ForkRefStatus::Orphan => { - let full_path = self.storage().dataset_uri(&entry.dataset_path); - crate::db::force_delete_orphan_ref(self, table_key, &full_path, native) - .await?; - } - crate::db::ForkRefStatus::Borrowed - | crate::db::ForkRefStatus::Legitimate - | crate::db::ForkRefStatus::Indeterminate => { - return Err(OmniError::manifest_conflict(format!( - "merge target ref '{table_key}:{native}' already exists while the \ - graph manifest inherits the table from another branch, and a \ - pending operation still claims it or its liveness could not be \ - verified; refusing to claim unowned physical state; retry" - ))); - } - } - } - } final_revalidation_timing.finish(); // Keep the sidecar alongside its handle: after the whole physical @@ -6253,7 +6232,7 @@ impl Omnigraph { CandidateTableState::RewriteMerged(_) | CandidateTableState::AdoptWithDelta(_) | CandidateTableState::AdoptPureInserts(_) => { - if first_touch_effects.contains(table_key) { + if first_touch_effects.contains_key(table_key) { None } else { Some(prepared_existing_targets.remove(table_key).ok_or_else(|| { @@ -6268,14 +6247,11 @@ impl Omnigraph { let update = match candidate_state { CandidateTableState::AdoptSourceState { .. } => { publish_adopted_source_state( - self, source_snapshot, target_snapshot, table_key, target_active.as_deref(), - recovery_operation_id.as_deref(), - ) - .await? + )? } CandidateTableState::AdoptWithDelta(delta) => { let planned = planned_transactions_by_table.get(table_key).ok_or_else(|| { @@ -6287,6 +6263,7 @@ impl Omnigraph { self, target_txn, recovery_operation_id.as_deref(), + first_touch_effects.get(table_key).map(String::as_str), table_key, identity, delta, @@ -6327,6 +6304,7 @@ impl Omnigraph { self, target_txn, recovery_operation_id.as_deref(), + first_touch_effects.get(table_key).map(String::as_str), table_key, identity, staged, @@ -6336,13 +6314,7 @@ impl Omnigraph { .await? } }; - if first_touch_effects.contains(table_key) { - let target = target_active.as_deref().ok_or_else(|| { - OmniError::manifest_internal(format!( - "first-touch merge effect '{}' has no named target branch", - table_key - )) - })?; + if let Some(target) = first_touch_effects.get(table_key) { let entry = target_snapshot .dataset(table_key) .or_else(|| source_snapshot.dataset(table_key)) diff --git a/crates/omnigraph/src/exec/staging.rs b/crates/omnigraph/src/exec/staging.rs index d5345654..74b11427 100644 --- a/crates/omnigraph/src/exec/staging.rs +++ b/crates/omnigraph/src/exec/staging.rs @@ -1103,7 +1103,7 @@ impl StagedMutation { // manifest on the happy path. let mut queue_keys: Vec<(String, Option)> = Vec::with_capacity(staged.len()); for entry in &staged { - queue_keys.push((entry.table_key.clone(), entry.path.table_branch.clone())); + queue_keys.push((entry.table_key.clone(), branch.map(str::to_string))); } // Total order shared with schema apply: schema gate, branch gate, then // sorted per-table gates. Hold the full set through manifest publish. @@ -1161,20 +1161,6 @@ impl StagedMutation { // the target branch fresh on any mismatch, returning the snapshot from // that same authority view. No prepared table pin is patched forward. let snapshot = db.revalidate_write_txn(txn).await?; - // A merge from main detaches the target's former ref while a child may - // still pin it; a first-touch fork must not reclaim that history, and - // the recovery envelope cannot represent reseeding an existing lineage, - // so the proof runs before arming. - let fork_references = if staged - .iter() - .any(|entry| entry.path.deferred_fork.is_some()) - { - Some(Box::pin(crate::db::manifest::ManifestCoordinator::native_fork_references_under_control_gates( - db.root_uri(), &db.control_session(), - )).await?) - } else { - None - }; for entry in &staged { let current = snapshot .dataset(&entry.table_key) @@ -1199,22 +1185,7 @@ impl StagedMutation { )); } - // A deferred fork is intentionally staged from the exact inherited - // source entry. The source ref (often main) may advance after the - // graph branch was cut; that is unrelated to this branch's pinned - // snapshot. The liveness proof above excludes borrowed target refs; - // any remaining orphan collision is checked under the same gates. - if let Some(fork) = entry.path.deferred_fork.as_ref() { - if fork_references - .as_ref() - .expect("deferred-fork liveness proof") - .contains(entry.path.identity, &fork.target_branch) - { - return Err(crate::db::manifest::detached_native_lineage_error( - &entry.table_key, - &fork.target_branch, - )); - } + if entry.path.deferred_fork.is_some() { if entry.dataset.version() != current { return Err(OmniError::manifest_read_set_changed( format!("published_dataset_version:{}", entry.table_key), @@ -1222,34 +1193,6 @@ impl StagedMutation { Some(entry.dataset.version().to_string()), )); } - let branches = - crate::branch_control::list_branch_contents(entry.dataset.dataset()).await?; - if branches.contains_key(&fork.target_branch) { - match crate::db::classify_fork_ref_with_references( - db, - entry.path.identity, - &fork.target_branch, - None, - fork_references - .as_ref() - .expect("deferred-fork liveness proof"), - ) - .await - { - crate::db::ForkRefStatus::Orphan => { - crate::db::force_delete_orphan_ref( - db, - &entry.table_key, - &entry.path.full_path, - &fork.target_branch, - ) - .await?; - } - crate::db::ForkRefStatus::Borrowed - | crate::db::ForkRefStatus::Legitimate - | crate::db::ForkRefStatus::Indeterminate => {} - } - } continue; } @@ -1280,6 +1223,25 @@ impl StagedMutation { }); } + let table_fork_owner = match txn.branch.as_deref() { + None => None, + Some(branch) => Some(txn.base.native_branch().ok_or_else(|| { + OmniError::manifest_internal(format!( + "branch '{branch}' has no captured native fork owner" + )) + })?), + }; + for entry in &mut staged { + if let Some(fork) = entry.path.deferred_fork.as_mut() { + fork.target_branch = crate::branch_names::table_fork_name( + &fork.target_branch, + txn.base.graph_manifest_version(), + &lineage_intent.graph_commit_id, + ); + entry.path.table_branch = Some(fork.target_branch.clone()); + } + } + // Sidecar protocol: build the per-table pin list and write the // sidecar BEFORE any `commit_staged` advances Lance HEAD, so any // commit→publish residual is recoverable on the next open. Deletes @@ -1298,6 +1260,7 @@ impl StagedMutation { post_commit_pin: entry.expected_version + 1, confirmed_version: None, table_branch: entry.path.table_branch.clone(), + table_fork_owner: table_fork_owner.map(str::to_string), }); } @@ -1388,11 +1351,6 @@ impl StagedMutation { { Ok(staged_write) => staged_write, Err(error) => { - // Strict preflight can discover an inherited match - // only after the first-touch target ref exists. The - // sidecar is already Armed, so return KeyConflict - // only after exact classification removes the - // untouched fork and retires the empty intent. if matches!(&error, OmniError::KeyConflict { .. }) { match crate::db::manifest::finalize_effect_free_occ_sidecar( db.root_uri(), @@ -1430,14 +1388,6 @@ impl StagedMutation { entry.staged_write = Some(staged_write); } Err(error) => { - // Once the intent is durable, a fork error is not proof that - // no physical effect occurred. Lance may have created the ref - // successfully and then failed while reopening/verifying it; - // reclaim-and-refork can likewise fail after deleting or - // recreating a ref. Keep the sidecar for exact Full recovery - // even when this is the first table. A future typed storage - // outcome may recover the pre-effect retry optimization only - // when it can *prove* the ref was never changed. return Err(OmniError::recovery_required( operation_id, format!( @@ -1584,7 +1534,9 @@ impl StagedMutation { published_dataset_version: state.version, native_dataset_branch: path.table_branch.clone(), entity_count: state.row_count, - version_metadata: state.version_metadata, + version_metadata: state + .version_metadata + .with_table_fork_owner(table_fork_owner), }); crate::failpoints::maybe_fail(crate::failpoints::names::MUTATION_POST_TABLE_COMMIT) .map_err(|error| { diff --git a/crates/omnigraph/src/failpoints.rs b/crates/omnigraph/src/failpoints.rs index c38b6e50..f4587270 100644 --- a/crates/omnigraph/src/failpoints.rs +++ b/crates/omnigraph/src/failpoints.rs @@ -141,7 +141,6 @@ pub mod names { /// 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. @@ -237,7 +236,6 @@ pub mod names { pub const ENSURE_INDICES_POST_STAGE_PRE_COMMIT_BTREE: &str = "ensure_indices.post_stage_pre_commit_btree"; pub const FORK_BEFORE_CLASSIFY: &str = "fork.before_classify"; - pub const FORK_BEFORE_RECLAIM: &str = "fork.before_reclaim"; /// After Lance durably creates a target table ref, before the caller can /// reopen and verify it. An error here is post-effect and must retain the /// recovery sidecar. diff --git a/crates/omnigraph/src/instrumentation.rs b/crates/omnigraph/src/instrumentation.rs index d7258326..101663bf 100644 --- a/crates/omnigraph/src/instrumentation.rs +++ b/crates/omnigraph/src/instrumentation.rs @@ -1494,13 +1494,20 @@ pub(crate) async fn open_dataset( .cloned() .unwrap_or_else(crate::lance_access::control_session); builder = builder.with_session(session); + let mut store_params = crate::storage::lance_store_params_for_uri(uri)?; if let Some(wrapper) = wrapper { - let mut store_params = crate::storage::lance_store_params_for_uri(uri)?; store_params.object_store_wrapper = Some(wrapper); - builder = builder.with_store_params(store_params); - } else { - builder = builder.with_store_params(crate::storage::lance_store_params_for_uri(uri)?); } + let handler = crate::storage_layer::lance_clone::configured_commit_handler( + uri, + &Some(store_params.clone()), + None, + ) + .await + .map_err(OmniError::storage)?; + builder = builder + .with_store_params(store_params) + .with_commit_handler(handler); builder.load().await.map_err(|error| match error { // Only the two shapes cleanup/drop legitimately leaves behind for a // pinned historical read count as reclaimed history: diff --git a/crates/omnigraph/src/storage_layer.rs b/crates/omnigraph/src/storage_layer.rs index 78d5405c..4dfcdb19 100644 --- a/crates/omnigraph/src/storage_layer.rs +++ b/crates/omnigraph/src/storage_layer.rs @@ -41,6 +41,8 @@ //! inline-commit residual. Phase 7 (recovery reconciler) shipped as MR-847; //! Phase 8 (index reconciler) is tracked as MR-848. +pub(crate) mod lance_clone; + use std::fmt::Debug; use std::sync::Arc; @@ -381,26 +383,6 @@ pub(crate) fn staged_handles_as_writes(handles: &[StagedHandle]) -> Vec { - Created(D), - RefAlreadyExists, -} - // ─── TableStorage trait ──────────────────────────────────────────────────── /// Engine-internal trait covering every Lance dataset operation an @@ -446,7 +428,7 @@ pub trait TableStorage: sealed::Sealed + Send + Sync + Debug { table_key: &str, source_version: u64, target_branch: &str, - ) -> Result>; + ) -> Result; /// Idempotent branch-tree reclaim used by the best-effort fork cleanup /// under branch delete (`db/omnigraph.rs::cleanup_deleted_branch_tables`) @@ -848,22 +830,17 @@ impl TableStorage for TableStore { table_key: &str, source_version: u64, target_branch: &str, - ) -> Result> { - Ok( - match TableStore::fork_branch_from_state( - self, - dataset_uri, - source_branch, - table_key, - source_version, - target_branch, - ) - .await? - { - ForkOutcome::Created(ds) => ForkOutcome::Created(SnapshotHandle::new(ds)), - ForkOutcome::RefAlreadyExists => ForkOutcome::RefAlreadyExists, - }, + ) -> Result { + let dataset = TableStore::fork_branch_from_state( + self, + dataset_uri, + source_branch, + table_key, + source_version, + target_branch, ) + .await?; + Ok(SnapshotHandle::new(dataset)) } async fn force_delete_branch(&self, dataset_uri: &str, branch: &str) -> Result<()> { diff --git a/crates/omnigraph/src/storage_layer/lance_clone.rs b/crates/omnigraph/src/storage_layer/lance_clone.rs new file mode 100644 index 00000000..38fd8e48 --- /dev/null +++ b/crates/omnigraph/src/storage_layer/lance_clone.rs @@ -0,0 +1,1257 @@ +//! Index-origin preservation at Lance's public shallow-clone commit boundary. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use futures::stream::BoxStream; +use lance::Dataset; +use lance::io::ObjectStore; +use lance_core::{Error, Result}; +use lance_table::format::pb::fragment_reuse_index_details::{Content, InlineContent}; +use lance_table::format::pb::{FragmentReuseIndexDetails, transaction}; +use lance_table::format::{BasePath, IndexMetadata, Manifest, Transaction}; +use lance_table::io::commit::{ + CommitError, CommitHandler, ManifestLocation, ManifestNamingScheme, ManifestWriter, +}; +use lance_table::io::manifest::read_manifest_indexes; +use object_store::ObjectStore as RawObjectStore; +use object_store::path::Path; +use prost::Message; + +tokio::task_local! { + static CLONE_CONTEXT: CloneContext; +} + +struct CloneContext { + operation: transaction::Clone, + target: Path, + source_indices: Vec, + inline_fragment_reuse_details: HashMap>, + expected_bases: HashMap, + new_base_id: u32, +} + +impl CloneContext { + async fn capture(source: &Dataset, branch: &str, source_version: u64) -> Result { + lance::dataset::refs::check_valid_branch(branch)?; + if source.version().version != source_version { + return Err(Error::invalid_input(format!( + "clone source version changed: expected {source_version}, got {}", + source.version().version, + ))); + } + let source_indices = if source.manifest().index_section.is_some() { + let store = source.object_store(None).await?; + read_manifest_indexes(&store, source.manifest_location(), source.manifest()).await? + } else { + Vec::new() + }; + let mut inline_fragment_reuse_details = HashMap::new(); + for (ordinal, index) in source_indices.iter().enumerate() { + if let Some(details) = normalize_fragment_reuse(source, index).await? { + inline_fragment_reuse_details.insert(ordinal, details); + } + } + let new_base_id = match source.manifest().base_paths.keys().max() { + Some(id) => id + .checked_add(1) + .ok_or_else(|| Error::invalid_input("clone base ID overflow"))?, + None => 0, + }; + let operation = transaction::Clone { + is_shallow: true, + ref_name: source.manifest().branch.clone(), + ref_version: source_version, + ref_path: source.uri().to_string(), + branch_name: Some(branch.to_string()), + }; + let mut expected_bases = source.manifest().base_paths.clone(); + expected_bases.insert( + new_base_id, + BasePath::new( + new_base_id, + operation.ref_path.clone(), + operation.ref_name.clone(), + true, + ), + ); + Ok(Self { + operation, + target: source.branch_location().find_branch(Some(branch))?.path, + source_indices, + inline_fragment_reuse_details, + expected_bases, + new_base_id, + }) + } + + fn correct( + &self, + manifest: &Manifest, + indices: Option>, + base_path: &Path, + transaction: &Transaction, + ) -> Result>> { + let transaction = transaction.as_pb(); + if base_path != &self.target + || transaction.read_version != self.operation.ref_version + || transaction.operation.as_ref() + != Some(&transaction::Operation::Clone(self.operation.clone())) + || manifest.branch != self.operation.branch_name + || manifest.base_paths != self.expected_bases + { + return Err(Error::invalid_input( + "shallow clone does not match its captured source and target", + )); + } + let Some(mut indices) = indices else { + return if self.source_indices.is_empty() { + Ok(None) + } else { + Err(Error::invalid_input( + "shallow clone lost its captured index metadata", + )) + }; + }; + if indices.len() != self.source_indices.len() { + return Err(Error::invalid_input( + "shallow clone index inventory changed", + )); + } + for (ordinal, (index, source)) in indices.iter_mut().zip(&self.source_indices).enumerate() { + let expected_base = source.base_id.or(Some(self.new_base_id)); + if index.base_id != Some(self.new_base_id) && index.base_id != expected_base { + return Err(Error::invalid_input(format!( + "shallow clone index {} has an unexpected base ID", + index.uuid + ))); + } + let mut comparable = index.clone(); + comparable.base_id = source.base_id; + if comparable != *source { + return Err(Error::invalid_input(format!( + "shallow clone changed metadata of index {} beyond its base ID", + index.uuid + ))); + } + index.base_id = expected_base; + if let Some(inline) = self.inline_fragment_reuse_details.get(&ordinal) { + let details = index + .index_details + .as_mut() + .ok_or_else(|| Error::index("captured fragment-reuse details disappeared"))?; + Arc::make_mut(details).value = inline.clone(); + } + } + Ok(Some(indices)) + } +} + +async fn normalize_fragment_reuse( + source: &Dataset, + index: &IndexMetadata, +) -> Result>> { + let Some(details) = index + .index_details + .as_ref() + .filter(|details| details.type_url.ends_with("FragmentReuseIndexDetails")) + else { + return Ok(None); + }; + let details = details.to_msg::()?; + match details.content { + Some(Content::Inline(_)) => Ok(None), + Some(Content::External(external)) => { + let directory = match index.base_id { + None => source.indices_dir(), + Some(base_id) => { + let base = source.manifest().base_paths.get(&base_id).ok_or_else(|| { + Error::index(format!( + "fragment-reuse index {} refers to missing base {base_id}", + index.uuid + )) + })?; + let path = base.extract_path(source.session().store_registry())?; + if base.is_dataset_root { + path.join("_indices") + } else { + path + } + } + }; + let path = directory.join(index.uuid.to_string()).join(external.path); + let store = source.object_store(index.base_id).await?; + let reader = store.open(&path).await?; + let range = fragment_reuse_range(external.offset, external.size, reader.size().await?)?; + let bytes = reader.get_range(range).await?; + let inline = InlineContent::decode(bytes)?; + lance_table::system_index::frag_reuse::FragReuseIndexDetails::try_from(inline.clone())?; + Ok(Some( + FragmentReuseIndexDetails { + content: Some(Content::Inline(inline)), + } + .encode_to_vec(), + )) + } + None => Err(Error::index("fragment-reuse index details have no content")), + } +} + +fn fragment_reuse_range( + offset: u64, + size: u64, + file_size: usize, +) -> Result> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::index("fragment-reuse detail range overflows"))?; + let start = usize::try_from(offset) + .map_err(|_| Error::index("fragment-reuse detail offset is not addressable"))?; + let end = usize::try_from(end) + .map_err(|_| Error::index("fragment-reuse detail end is not addressable"))?; + if end > file_size { + return Err(Error::index(format!( + "fragment-reuse detail range ends at {end}, beyond file length {file_size}" + ))); + } + Ok(start..end) +} + +#[derive(Debug)] +struct IndexOriginCommitHandler { + inner: Arc, +} + +pub(crate) fn wrap_commit_handler(inner: Arc) -> Arc { + Arc::new(IndexOriginCommitHandler { inner }) +} + +pub(crate) async fn configured_commit_handler( + uri: &str, + params: &Option, + existing: Option>, +) -> Result> { + let handler = match existing { + Some(handler) => handler, + None => lance_table::io::commit::commit_handler_from_url(uri, params).await?, + }; + Ok(wrap_commit_handler(handler)) +} + +pub(crate) async fn write_params( + uri: &str, + mut params: lance::dataset::WriteParams, +) -> Result { + params.commit_handler = Some( + configured_commit_handler(uri, ¶ms.store_params, params.commit_handler.take()).await?, + ); + Ok(params) +} + +pub(crate) async fn create_branch( + source: &mut Dataset, + branch: &str, + source_version: u64, +) -> Result { + let context = CloneContext::capture(source, branch, source_version).await?; + CLONE_CONTEXT + .scope(context, source.create_branch(branch, source_version, None)) + .await +} + +#[async_trait] +impl CommitHandler for IndexOriginCommitHandler { + fn is_version_not_found_definitive(&self) -> bool { + self.inner.is_version_not_found_definitive() + } + + fn propagate_commit_error_after_success(&self) -> bool { + self.inner.propagate_commit_error_after_success() + } + + async fn resolve_latest_location( + &self, + base_path: &Path, + object_store: &ObjectStore, + ) -> Result { + self.inner + .resolve_latest_location(base_path, object_store) + .await + } + + async fn resolve_version_location( + &self, + base_path: &Path, + version: u64, + object_store: &dyn RawObjectStore, + ) -> Result { + self.inner + .resolve_version_location(base_path, version, object_store) + .await + } + + async fn version_exists( + &self, + base_path: &Path, + version: u64, + object_store: &dyn RawObjectStore, + naming_scheme: ManifestNamingScheme, + ) -> Result { + self.inner + .version_exists(base_path, version, object_store, naming_scheme) + .await + } + + fn list_detached_manifest_locations<'a>( + &self, + base_path: &Path, + object_store: &'a ObjectStore, + ) -> BoxStream<'a, Result> { + self.inner + .list_detached_manifest_locations(base_path, object_store) + } + + fn list_manifest_locations<'a>( + &self, + base_path: &Path, + object_store: &'a ObjectStore, + sorted_descending: bool, + ) -> BoxStream<'a, Result> { + self.inner + .list_manifest_locations(base_path, object_store, sorted_descending) + } + + fn list_manifest_locations_since<'a>( + &self, + base_path: &Path, + object_store: &'a ObjectStore, + since_version: u64, + ) -> BoxStream<'a, Result> { + self.inner + .list_manifest_locations_since(base_path, object_store, since_version) + } + + async fn commit( + &self, + manifest: &mut Manifest, + indices: Option>, + base_path: &Path, + object_store: &ObjectStore, + manifest_writer: ManifestWriter, + naming_scheme: ManifestNamingScheme, + transaction: Option, + ) -> std::result::Result { + let indices = match transaction.as_ref() { + Some(transaction) if matches!(transaction.as_pb().operation.as_ref(), Some(transaction::Operation::Clone(operation)) if operation.is_shallow) => { + CLONE_CONTEXT + .try_with(|context| context.correct(manifest, indices, base_path, transaction)) + .map_err(|_| { + Error::invalid_input("shallow clone has no scoped source-index context") + })?? + } + _ => { + if CLONE_CONTEXT + .try_with(|context| context.target == *base_path) + .unwrap_or(false) + { + return Err(Error::invalid_input( + "scoped shallow clone has no matching inline transaction", + ) + .into()); + } + indices + } + }; + self.inner + .commit( + manifest, + indices, + base_path, + object_store, + manifest_writer, + naming_scheme, + transaction, + ) + .await + } + + async fn delete(&self, base_path: &Path) -> Result<()> { + self.inner.delete(base_path).await + } +} + +#[cfg(test)] +mod tests { + #[derive(Debug)] + struct ConfiguredBackend; + + #[async_trait] + impl CommitHandler for ConfiguredBackend { + fn is_version_not_found_definitive(&self) -> bool { + true + } + fn propagate_commit_error_after_success(&self) -> bool { + false + } + + async fn resolve_version_location( + &self, + _base_path: &Path, + _version: u64, + _object_store: &dyn RawObjectStore, + ) -> Result { + Err(Error::io("configured resolver")) + } + + async fn commit( + &self, + _manifest: &mut Manifest, + _indices: Option>, + _base_path: &Path, + _object_store: &ObjectStore, + _writer: ManifestWriter, + _scheme: ManifestNamingScheme, + _transaction: Option, + ) -> std::result::Result { + Err(Error::io("configured commit").into()) + } + } + + #[tokio::test] + async fn creation_preserves_configured_handler_session_and_failure_semantics() { + let session = Arc::new(Session::default()); + let params = write_params( + "custom-commit-provider://dataset", + WriteParams { + commit_handler: Some(Arc::new(ConfiguredBackend)), + session: Some(session.clone()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!(Arc::ptr_eq(params.session.as_ref().unwrap(), &session)); + let handler = params.commit_handler.unwrap(); + assert!(handler.is_version_not_found_definitive()); + assert!(!handler.propagate_commit_error_after_success()); + let backend = object_store::memory::InMemory::new(); + let error = handler + .resolve_version_location(&Path::from("dataset"), 1, &backend) + .await + .unwrap_err(); + assert!(error.to_string().contains("configured resolver")); + let dir = tempfile::tempdir().unwrap(); + let mut source = fresh_dataset(dir.path().join("init.lance").to_str().unwrap()).await; + let error = handler + .commit( + Arc::make_mut(&mut source.manifest), + None, + &Path::from("dataset"), + &ObjectStore::from_uri("memory://delegation") + .await + .unwrap() + .0, + lance_table::io::commit::write_manifest_file_to_path, + ManifestNamingScheme::V1, + None, + ) + .await + .unwrap_err(); + assert!(Error::from(error).to_string().contains("configured commit")); + } + + #[tokio::test] + async fn newly_created_dataset_can_fork_twice_without_reopening() { + let dir = tempfile::tempdir().unwrap(); + let uri = dir.path().join("fresh.lance"); + let uri = uri.to_str().unwrap(); + let mut source = fresh_dataset(uri).await; + source + .create_index_builder(&["value"], IndexType::BTree, &ScalarIndexParams::default()) + .replace(true) + .await + .unwrap(); + let version = source.version().version; + let mut first = create_branch(&mut source, "first", version).await.unwrap(); + let version = first.version().version; + let second = create_branch(&mut first, "second", version).await.unwrap(); + let index = second.load_indices().await.unwrap()[0].clone(); + assert_eq!( + second.manifest().base_paths[&index.base_id.unwrap()].path, + source.uri() + ); + let mut scan = second.scan(); + scan.filter("value = 1").unwrap(); + assert!( + scan.explain_plan(true) + .await + .unwrap() + .contains("ScalarIndexQuery") + ); + assert_eq!(scan.try_into_batch().await.unwrap().num_rows(), 1); + } + use super::*; + use arrow_array::{FixedSizeListArray, Float32Array}; + use arrow_array::{Int32Array, RecordBatch, RecordBatchIterator, StringArray}; + use arrow_schema::{DataType, Field, Schema}; + use futures::TryStreamExt; + use lance::dataset::builder::DatasetBuilder; + use lance::dataset::{WriteMode, WriteParams}; + use lance::index::DatasetIndexExt; + use lance::index::vector::VectorIndexParams; + use lance::session::Session; + use lance_file::version::LanceFileVersion; + use lance_index::IndexType; + use lance_index::scalar::{FullTextSearchQuery, InvertedIndexParams, ScalarIndexParams}; + use lance_linalg::distance::MetricType; + async fn fresh_dataset(uri: &str) -> Dataset { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Utf8, false), + Field::new("value", DataType::Int32, false), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(StringArray::from(vec!["alice", "bob"])), + Arc::new(Int32Array::from(vec![1, 2])), + ], + ) + .unwrap(); + let reader = RecordBatchIterator::new(vec![Ok(batch)], schema); + let params = WriteParams { + mode: WriteMode::Create, + enable_stable_row_ids: true, + data_storage_version: Some(LanceFileVersion::V2_2), + ..Default::default() + }; + let params = write_params(uri, params).await.unwrap(); + Dataset::write(reader, uri, Some(params)).await.unwrap() + } + + async fn append_guard_row(dataset: &mut Dataset, id: &str, value: i32) { + let schema = Arc::new(Schema::from(dataset.schema())); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(StringArray::from(vec![id])), + Arc::new(Int32Array::from(vec![value])), + ], + ) + .unwrap(); + let reader = RecordBatchIterator::new(vec![Ok(batch)], schema); + dataset + .append( + reader, + Some(WriteParams { + mode: WriteMode::Append, + enable_stable_row_ids: true, + data_storage_version: Some(LanceFileVersion::V2_2), + ..Default::default() + }), + ) + .await + .unwrap(); + } + #[tokio::test] + async fn second_and_third_generation_clone_preserves_inherited_and_local_index_bases() { + let dir = tempfile::tempdir().unwrap(); + let uri = dir.path().join("guard24.lance"); + let uri = uri.to_str().unwrap(); + let mut ds = fresh_dataset(uri).await; + ds.create_index_builder(&["value"], IndexType::BTree, &ScalarIndexParams::default()) + .replace(true) + .await + .unwrap(); + let mut ds = engine_open(uri).await; + let root_indices = ds.load_indices().await.unwrap(); + assert_eq!(root_indices.len(), 1); + let inherited_uuid = root_indices[0].uuid; + assert_eq!(root_indices[0].base_id, None); + + let version = ds.version().version; + let mut feature = create_branch(&mut ds, "feature", version).await.unwrap(); + append_guard_row(&mut feature, "carol", 3).await; + feature + .create_index_builder(&["id"], IndexType::BTree, &ScalarIndexParams::default()) + .replace(true) + .await + .unwrap(); + let feature_indices = feature.load_indices().await.unwrap(); + assert_eq!(feature_indices.len(), 2); + let inherited = feature_indices + .iter() + .find(|index| index.uuid == inherited_uuid) + .unwrap(); + let inherited_base = inherited + .base_id + .expect("first clone redirects its inherited index"); + let local = feature_indices + .iter() + .find(|index| index.uuid != inherited_uuid) + .unwrap(); + assert_eq!( + local.base_id, None, + "newly built index belongs to feature's own tree" + ); + let local_uuid = local.uuid; + let feature_version = feature.version().version; + let mut experiment = create_branch(&mut feature, "experiment", feature_version) + .await + .unwrap(); + + async fn indexed_rows(dataset: &Dataset, filter: &str) -> Vec<(String, i32)> { + let mut scanner = dataset.scan(); + scanner.filter(filter).unwrap(); + let plan = scanner.explain_plan(true).await.unwrap(); + assert!( + plan.contains("ScalarIndexQuery"), + "regression must open the index: {plan}" + ); + let batches: Vec = scanner + .try_into_stream() + .await + .unwrap() + .try_collect() + .await + .unwrap(); + let mut rows = Vec::new(); + for batch in batches { + let ids = batch + .column_by_name("id") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + let values = batch + .column_by_name("value") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + rows.extend( + (0..batch.num_rows()) + .map(|row| (ids.value(row).to_string(), values.value(row))), + ); + } + rows.sort_unstable(); + rows + } + + let reopened = DatasetBuilder::from_uri(uri) + .with_session(Arc::new(Session::default())) + .load() + .await + .unwrap() + .checkout_branch("experiment") + .await + .unwrap(); + for dataset in [&experiment, &reopened] { + let indices = dataset.load_indices().await.unwrap(); + assert_eq!(indices.len(), 2); + let inherited = indices + .iter() + .find(|index| index.uuid == inherited_uuid) + .unwrap(); + assert_eq!(inherited.base_id, Some(inherited_base)); + assert_eq!( + dataset.manifest().base_paths[&inherited_base].path, + feature.manifest().base_paths[&inherited_base].path, + ); + let local = indices + .iter() + .find(|index| index.uuid == local_uuid) + .unwrap(); + let local_base = local + .base_id + .expect("second clone redirects feature's local index"); + assert_ne!(local_base, inherited_base); + let local_path = &dataset.manifest().base_paths[&local_base]; + assert!(local_path.is_dataset_root); + assert_eq!(local_path.path, feature.uri()); + assert_eq!( + indexed_rows(dataset, "value = 1").await, + vec![("alice".to_string(), 1)] + ); + assert_eq!( + indexed_rows(dataset, "id = 'carol'").await, + vec![("carol".to_string(), 3)] + ); + } + let experiment_indices = experiment.load_indices().await.unwrap(); + let version = experiment.version().version; + let third = create_branch(&mut experiment, "third", version) + .await + .unwrap(); + let third_reopened = DatasetBuilder::from_uri(uri) + .with_session(Arc::new(Session::default())) + .load() + .await + .unwrap() + .checkout_branch("third") + .await + .unwrap(); + for dataset in [&third, &third_reopened] { + let indices = dataset.load_indices().await.unwrap(); + assert_eq!(indices.len(), experiment_indices.len()); + for source_index in experiment_indices.iter() { + let inherited = indices + .iter() + .find(|index| index.uuid == source_index.uuid) + .unwrap(); + assert!(source_index.base_id.is_some()); + assert_eq!(inherited.base_id, source_index.base_id); + let base = source_index.base_id.unwrap(); + assert_eq!( + dataset.manifest().base_paths[&base], + experiment.manifest().base_paths[&base] + ); + } + assert_eq!( + indexed_rows(dataset, "value = 1").await, + vec![("alice".to_string(), 1)] + ); + assert_eq!( + indexed_rows(dataset, "id = 'carol'").await, + vec![("carol".to_string(), 3)] + ); + } + append_guard_row(&mut experiment, "dave", 4).await; + let after_append = DatasetBuilder::from_uri(uri) + .with_session(Arc::new(Session::default())) + .load() + .await + .unwrap() + .checkout_branch("experiment") + .await + .unwrap(); + assert_eq!( + indexed_rows(&after_append, "id = 'dave'").await, + vec![("dave".to_string(), 4)] + ); + } + + async fn engine_open(uri: &str) -> Dataset { + crate::instrumentation::open_dataset( + uri, + crate::instrumentation::VersionResolution::Latest, + Some(&Arc::new(Session::default())), + None, + ) + .await + .unwrap() + } + + async fn indexed_source(uri: &str) -> Dataset { + let mut dataset = fresh_dataset(uri).await; + dataset + .create_index_builder(&["value"], IndexType::BTree, &ScalarIndexParams::default()) + .replace(true) + .await + .unwrap(); + engine_open(uri).await + } + + #[tokio::test] + async fn clone_context_is_required_and_bound_before_manifest_publication() { + let dir = tempfile::tempdir().unwrap(); + let uri = dir.path().join("scoped.lance"); + let uri = uri.to_str().unwrap(); + let mut source = indexed_source(uri).await; + let version = source.version().version; + let missing = source + .create_branch("missing", version, None) + .await + .unwrap_err(); + assert!( + missing + .to_string() + .contains("no scoped source-index context"), + "{missing}" + ); + let context = CloneContext::capture(&source, "expected", version) + .await + .unwrap(); + let mismatched = CLONE_CONTEXT + .scope(context, source.create_branch("wrong", version, None)) + .await + .unwrap_err(); + assert!( + mismatched + .to_string() + .contains("does not match its captured source and target"), + "{mismatched}" + ); + assert!(source.list_branches().await.unwrap().is_empty()); + assert_eq!(source.version().version, version); + for branch in ["missing", "wrong"] { + assert!(source.checkout_branch(branch).await.is_err()); + } + assert!(CLONE_CONTEXT.try_with(|_| ()).is_err()); + let good = create_branch(&mut source, "good", version).await.unwrap(); + assert_eq!(good.count_rows(None).await.unwrap(), 2); + assert!(CLONE_CONTEXT.try_with(|_| ()).is_err()); + } + + #[tokio::test] + async fn concurrent_clone_contexts_keep_each_source_index_origin() { + async fn branch_twice(uri: String) -> Dataset { + let mut source = indexed_source(&uri).await; + let source_index = source.load_indices().await.unwrap()[0].uuid; + let version = source.version().version; + let mut first = create_branch(&mut source, "first", version).await.unwrap(); + tokio::task::yield_now().await; + let version = first.version().version; + let second = create_branch(&mut first, "second", version).await.unwrap(); + let index = second.load_indices().await.unwrap()[0].clone(); + assert_eq!(index.uuid, source_index); + assert_eq!( + second.manifest().base_paths[&index.base_id.unwrap()].path, + source.uri() + ); + assert!(CLONE_CONTEXT.try_with(|_| ()).is_err()); + second + } + let dir = tempfile::tempdir().unwrap(); + let left_uri = dir.path().join("left.lance").to_str().unwrap().to_string(); + let right_uri = dir.path().join("right.lance").to_str().unwrap().to_string(); + let (left, right) = tokio::join!( + tokio::spawn(branch_twice(left_uri)), + tokio::spawn(branch_twice(right_uri)) + ); + let left = left.unwrap(); + let right = right.unwrap(); + assert_ne!( + left.load_indices().await.unwrap()[0].uuid, + right.load_indices().await.unwrap()[0].uuid + ); + } + + #[tokio::test] + async fn source_capture_reads_only_index_metadata_and_append_cost_is_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let empty_uri = dir.path().join("empty.lance"); + fresh_dataset(empty_uri.to_str().unwrap()).await; + let empty = engine_open(empty_uri.to_str().unwrap()).await; + let store = empty.object_store(None).await.unwrap(); + store.io_stats_incremental(); + CloneContext::capture(&empty, "child", empty.version().version) + .await + .unwrap(); + let empty_capture = store.io_stats_incremental(); + assert_eq!(empty_capture.read_iops, 0, "{empty_capture:?}"); + assert_eq!(empty_capture.write_iops, 0, "{empty_capture:?}"); + + let indexed_uri = dir.path().join("indexed.lance"); + let indexed = indexed_source(indexed_uri.to_str().unwrap()).await; + let store = indexed.object_store(None).await.unwrap(); + store.io_stats_incremental(); + CloneContext::capture(&indexed, "child", indexed.version().version) + .await + .unwrap(); + let indexed_capture = store.io_stats_incremental(); + assert_eq!( + indexed_capture.read_iops, 1, + "small indexed manifest capture: {indexed_capture:?}" + ); + assert_eq!(indexed_capture.write_iops, 0, "{indexed_capture:?}"); + + let raw_uri = dir.path().join("raw.lance"); + let engine_uri = dir.path().join("eng.lance"); + fresh_dataset(raw_uri.to_str().unwrap()).await; + fresh_dataset(engine_uri.to_str().unwrap()).await; + let mut raw = DatasetBuilder::from_uri(raw_uri.to_str().unwrap()) + .with_session(Arc::new(Session::default())) + .load() + .await + .unwrap(); + let mut engine = engine_open(engine_uri.to_str().unwrap()).await; + let raw_store = raw.object_store(None).await.unwrap(); + let engine_store = engine.object_store(None).await.unwrap(); + raw_store.io_stats_incremental(); + engine_store.io_stats_incremental(); + append_guard_row(&mut raw, "carol", 3).await; + let raw_cost = raw_store.io_stats_incremental(); + append_guard_row(&mut engine, "carol", 3).await; + let engine_cost = engine_store.io_stats_incremental(); + assert_eq!( + (engine_cost.read_iops, engine_cost.write_iops), + (raw_cost.read_iops, raw_cost.write_iops), + "raw={raw_cost:?}, engine={engine_cost:?}" + ); + for source in [&mut raw, &mut engine] { + source + .create_index_builder(&["value"], IndexType::BTree, &ScalarIndexParams::default()) + .replace(true) + .await + .unwrap(); + } + for branch in ["first", "second"] { + let raw_store = raw.object_store(None).await.unwrap(); + let engine_store = engine.object_store(None).await.unwrap(); + raw_store.io_stats_incremental(); + engine_store.io_stats_incremental(); + let version = raw.version().version; + raw = raw.create_branch(branch, version, None).await.unwrap(); + let raw_clone_cost = raw_store.io_stats_incremental(); + let version = engine.version().version; + engine = create_branch(&mut engine, branch, version).await.unwrap(); + let engine_clone_cost = engine_store.io_stats_incremental(); + assert!(raw_clone_cost.write_iops > 0, "{raw_clone_cost:?}"); + assert_eq!( + engine_clone_cost.write_iops, raw_clone_cost.write_iops, + "{branch}: raw={raw_clone_cost:?}, engine={engine_clone_cost:?}" + ); + } + assert!(CLONE_CONTEXT.try_with(|_| ()).is_err()); + } + + async fn external_fragment_reuse_source( + uri: &str, + payload: Vec, + offset: u64, + size: u64, + ) -> Dataset { + let mut source = fresh_dataset(uri).await; + source + .create_index_builder(&["value"], IndexType::BTree, &ScalarIndexParams::default()) + .replace(true) + .await + .unwrap(); + let mut metadata = source.load_indices().await.unwrap()[0].clone(); + metadata.uuid = "27a30b70-7fb8-4ee3-a731-35a1bcff9260".parse().unwrap(); + metadata.name = lance_table::system_index::frag_reuse::FRAG_REUSE_INDEX_NAME.to_string(); + metadata.fields.clear(); + metadata.covering_fields.clear(); + metadata.dataset_version = source.version().version; + metadata.index_version = 0; + metadata.base_id = None; + metadata.files = None; + let external = lance_table::format::pb::ExternalFile { + path: "details.binpb".to_string(), + offset, + size, + }; + let proto = FragmentReuseIndexDetails { + content: Some(Content::External(external)), + }; + let details = Arc::make_mut(metadata.index_details.as_mut().unwrap()); + details.type_url = "type.googleapis.com/lance.table.FragmentReuseIndexDetails".to_string(); + details.value = proto.encode_to_vec(); + let path = source + .indices_dir() + .join(metadata.uuid.to_string()) + .join("details.binpb"); + source + .object_store(None) + .await + .unwrap() + .inner + .put_opts(&path, payload.into(), object_store::PutOptions::default()) + .await + .unwrap(); + Dataset::commit( + uri, + lance::dataset::transaction::Operation::CreateIndex { + new_indices: vec![metadata], + removed_indices: vec![], + }, + Some(source.version().version), + source.store_params().cloned(), + None, + source.session().clone(), + false, + ) + .await + .unwrap() + } + + #[tokio::test] + async fn external_fragment_reuse_is_inlined_from_local_and_inherited_origins() { + let dir = tempfile::tempdir().unwrap(); + let uri = dir.path().join("external-fri.lance"); + let uri = uri.to_str().unwrap(); + let inline = InlineContent { + versions: vec![ + lance_table::format::pb::fragment_reuse_index_details::Version { + dataset_version: 1, + groups: vec![], + }, + ], + }; + let payload = inline.encode_to_vec(); + let payload_size = u64::try_from(payload.len()).unwrap(); + let mut raw = external_fragment_reuse_source(uri, payload, 0, payload_size).await; + let version = raw.version().version; + let stock_first = raw + .create_branch("stock_first", version, None) + .await + .unwrap(); + let mut local = engine_open(uri).await; + let mut inherited = engine_open(stock_first.uri()).await; + for (source, branch) in [ + (&mut local, "local_normalized"), + (&mut inherited, "inherited_normalized"), + ] { + let source_indices = read_manifest_indexes( + &source.object_store(None).await.unwrap(), + source.manifest_location(), + source.manifest(), + ) + .await + .unwrap(); + let source_fri = source_indices + .iter() + .find(|index| { + index.name == lance_table::system_index::frag_reuse::FRAG_REUSE_INDEX_NAME + }) + .unwrap(); + assert!(matches!( + source_fri + .index_details + .as_ref() + .unwrap() + .to_msg::() + .unwrap() + .content, + Some(Content::External(_)) + )); + let version = source.version().version; + let child = create_branch(source, branch, version).await.unwrap(); + let cold = DatasetBuilder::from_uri(uri) + .with_session(Arc::new(Session::default())) + .load() + .await + .unwrap() + .checkout_branch(branch) + .await + .unwrap(); + for dataset in [&child, &cold] { + let store = dataset.object_store(None).await.unwrap(); + let indices = + read_manifest_indexes(&store, dataset.manifest_location(), dataset.manifest()) + .await + .unwrap(); + let fri = indices + .iter() + .find(|index| index.uuid == source_fri.uuid) + .unwrap(); + let proto = fri + .index_details + .as_ref() + .unwrap() + .to_msg::() + .unwrap(); + assert_eq!(proto.content, Some(Content::Inline(inline.clone()))); + let base = fri.base_id.unwrap(); + assert_eq!(dataset.manifest().base_paths[&base].path, raw.uri()); + let loaded = lance::index::frag_reuse::load_frag_reuse_index_details(dataset, fri) + .await + .unwrap(); + assert_eq!(loaded.versions.len(), 1); + assert_eq!(loaded.versions[0].dataset_version, 1); + let mut scanner = dataset.scan(); + scanner.filter("value = 1").unwrap(); + assert!( + scanner + .explain_plan(true) + .await + .unwrap() + .contains("ScalarIndexQuery") + ); + assert_eq!(scanner.try_into_batch().await.unwrap().num_rows(), 1); + } + let original = read_manifest_indexes( + &source.object_store(None).await.unwrap(), + source.manifest_location(), + source.manifest(), + ) + .await + .unwrap(); + assert_eq!( + original + .iter() + .find(|index| index.uuid == source_fri.uuid) + .unwrap(), + source_fri + ); + } + } + + #[tokio::test] + async fn malformed_external_fragment_reuse_refuses_before_native_create() { + for (label, payload, offset, size) in [ + ("malformed", vec![0xff], 0, 1), + ("overflow", vec![0], u64::MAX, 1), + ("truncated", vec![0], 0, 2), + ] { + let dir = tempfile::tempdir().unwrap(); + let uri = dir.path().join(format!("{label}.lance")); + let uri = uri.to_str().unwrap(); + external_fragment_reuse_source(uri, payload, offset, size).await; + let mut source = engine_open(uri).await; + let version = source.version().version; + assert!( + create_branch(&mut source, "refused", version) + .await + .is_err() + ); + assert!(source.list_branches().await.unwrap().is_empty()); + let target = source + .branch_location() + .find_branch(Some("refused")) + .unwrap(); + assert!(!std::path::Path::new(&target.uri).exists()); + assert_eq!(source.version().version, version); + } + } + + #[test] + fn fragment_reuse_ranges_validate_file_bounds_before_reading() { + assert_eq!(fragment_reuse_range(2, 3, 5).unwrap(), 2..5); + assert_eq!(fragment_reuse_range(5, 0, 5).unwrap(), 5..5); + assert!(fragment_reuse_range(2, 4, 5).is_err()); + assert!(fragment_reuse_range(u64::MAX, 1, usize::MAX).is_err()); + } + + #[tokio::test] + async fn inherited_full_text_and_vector_indexes_survive_two_forks_and_cold_reopen() { + let dir = tempfile::tempdir().unwrap(); + let uri = dir.path().join("search.lance"); + let uri = uri.to_str().unwrap(); + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Utf8, false), + Field::new("text", DataType::Utf8, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2), + false, + ), + ])); + let vectors = FixedSizeListArray::try_new( + Arc::new(Field::new("item", DataType::Float32, true)), + 2, + Arc::new(Float32Array::from( + (0..256) + .flat_map(|row| [row as f32, 0.0]) + .collect::>(), + )), + None, + ) + .unwrap(); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(StringArray::from( + (0..256).map(|row| format!("row-{row}")).collect::>(), + )), + Arc::new(StringArray::from( + (0..256) + .map(|row| if row == 0 { "needle" } else { "other" }) + .collect::>(), + )), + Arc::new(vectors), + ], + ) + .unwrap(); + let mut source = Dataset::write( + RecordBatchIterator::new(vec![Ok(batch)], schema), + uri, + Some(WriteParams { + mode: WriteMode::Create, + enable_stable_row_ids: true, + data_storage_version: Some(LanceFileVersion::V2_2), + ..Default::default() + }), + ) + .await + .unwrap(); + source + .create_index_builder( + &["text"], + IndexType::Inverted, + &InvertedIndexParams::default(), + ) + .replace(true) + .await + .unwrap(); + source + .create_index_builder( + &["vector"], + IndexType::Vector, + &VectorIndexParams::ivf_flat(1, MetricType::L2), + ) + .replace(true) + .await + .unwrap(); + let original = source.load_indices().await.unwrap(); + let mut source = engine_open(uri).await; + let version = source.version().version; + let mut first = create_branch(&mut source, "first", version).await.unwrap(); + let version = first.version().version; + let second = create_branch(&mut first, "second", version).await.unwrap(); + let cold = DatasetBuilder::from_uri(uri) + .with_session(Arc::new(Session::default())) + .load() + .await + .unwrap() + .checkout_branch("second") + .await + .unwrap(); + for dataset in [&second, &cold] { + let indices = dataset.load_indices().await.unwrap(); + assert_eq!(indices.len(), original.len()); + for index in indices.iter() { + assert!(original.iter().any(|original| original.uuid == index.uuid)); + assert_eq!( + dataset.manifest().base_paths[&index.base_id.unwrap()].path, + source.uri() + ); + } + let mut text = dataset.scan(); + text.project(&["id"]).unwrap(); + text.full_text_search( + FullTextSearchQuery::new("needle".to_string()) + .with_column("text".to_string()) + .unwrap(), + ) + .unwrap(); + let text_plan = text.explain_plan(true).await.unwrap(); + assert!( + text_plan.contains("MatchQuery:") && !text_plan.contains("FlatMatchQuery"), + "{text_plan}" + ); + let result = text.try_into_batch().await.unwrap(); + assert_eq!(result.num_rows(), 1); + assert_eq!( + result + .column_by_name("id") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .value(0), + "row-0" + ); + let mut vector = dataset.scan(); + vector.project(&["id"]).unwrap(); + vector + .nearest("vector", &Float32Array::from(vec![0.0, 0.0]), 1) + .unwrap(); + let vector_plan = vector.explain_plan(true).await.unwrap(); + assert!(vector_plan.contains("ANNIvf"), "{vector_plan}"); + let result = vector.try_into_batch().await.unwrap(); + assert_eq!(result.num_rows(), 1); + assert_eq!( + result + .column_by_name("id") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .value(0), + "row-0" + ); + } + } +} diff --git a/crates/omnigraph/src/table_store.rs b/crates/omnigraph/src/table_store.rs index 0a43f147..f0e3aeed 100644 --- a/crates/omnigraph/src/table_store.rs +++ b/crates/omnigraph/src/table_store.rs @@ -65,7 +65,7 @@ use crate::db::manifest::TableVersionMetadata; use crate::db::{DatasetEntry, Snapshot}; use crate::error::{OmniError, Result}; use crate::storage_layer::{ - ForkOutcome, IndexBuildSpec, KEYED_WRITE_MAX_BYTES, KEYED_WRITE_MAX_ROWS, KeyedWriteSemantics, + IndexBuildSpec, KEYED_WRITE_MAX_BYTES, KEYED_WRITE_MAX_ROWS, KeyedWriteSemantics, PendingScanBudget, ProvenInsertChunk, }; @@ -1246,8 +1246,7 @@ impl TableStore { /// 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. + /// Used by the explicit `cleanup` orphan reconciler. pub async fn force_delete_branch(&self, dataset_uri: &str, branch: &str) -> Result<()> { let mut ds = crate::instrumentation::open_dataset( dataset_uri, @@ -1300,7 +1299,7 @@ impl TableStore { type_key: &str, source_version: u64, target_branch: &str, - ) -> Result> { + ) -> Result { let mut source_ds = self .open_dataset_head(dataset_uri, source_branch) .await? @@ -1309,18 +1308,12 @@ impl TableStore { .map_err(OmniError::storage)?; self.ensure_expected_version(&source_ds, type_key, source_version)?; - let created = match crate::branch_control::create_branch_recoverably( + let created = crate::branch_control::create_unique_table_fork( &mut source_ds, target_branch, source_version, ) - .await? - { - crate::branch_control::BranchCreateOutcome::Created(dataset) => *dataset, - crate::branch_control::BranchCreateOutcome::RefAlreadyExists => { - return Ok(ForkOutcome::RefAlreadyExists); - } - }; + .await?; // 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 @@ -1335,7 +1328,7 @@ impl TableStore { .open_dataset_head(dataset_uri, Some(target_branch)) .await?; self.ensure_expected_version(&ds, type_key, source_version)?; - Ok(ForkOutcome::Created(ds)) + Ok(ds) } pub async fn scan_batches(&self, ds: &Dataset) -> Result> { @@ -2484,6 +2477,9 @@ impl TableStore { session: Some(control_session), ..Default::default() }; + let params = crate::storage_layer::lance_clone::write_params(dataset_uri, params) + .await + .map_err(OmniError::storage)?; Dataset::write(reader, dataset_uri, Some(params)) .await .map_err(OmniError::storage) @@ -3698,7 +3694,15 @@ impl TableStore { } let store_params = crate::storage::lance_store_params_for_uri(dataset_uri)?; + let handler = crate::storage_layer::lance_clone::configured_commit_handler( + dataset_uri, + &Some(store_params.clone()), + None, + ) + .await + .map_err(OmniError::storage)?; let dataset = CommitBuilder::new(dataset_uri) + .with_commit_handler(handler) .use_stable_row_ids(true) .with_storage_format(LanceFileVersion::V2_2) .enable_v2_manifest_paths(true) @@ -4533,6 +4537,9 @@ impl TableStore { session: Some(control_session), ..Default::default() }; + let params = crate::storage_layer::lance_clone::write_params(dataset_uri, params) + .await + .map_err(OmniError::storage)?; Dataset::write(reader, dataset_uri, Some(params)) .await .map_err(OmniError::storage) diff --git a/crates/omnigraph/src/table_store/fts_compat.rs b/crates/omnigraph/src/table_store/fts_compat.rs index 3f2f0e97..5a122755 100644 --- a/crates/omnigraph/src/table_store/fts_compat.rs +++ b/crates/omnigraph/src/table_store/fts_compat.rs @@ -505,12 +505,15 @@ mod tests { let original = object_store.read_one_all(&path).await.unwrap(); - let clone_uri = directory.path().join("clone.lance"); + let clone_uri = directory.path().join("source.lance/tree/certificate-clone"); let version = dataset.version().version; - dataset - .shallow_clone(clone_uri.to_str().unwrap(), version, None) - .await - .unwrap(); + crate::storage_layer::lance_clone::create_branch( + &mut dataset, + "certificate-clone", + version, + ) + .await + .unwrap(); let mut cloned = // forbidden-api-allow: test-only same-session clone proves certificate ownership cannot alias by UUID. lance::dataset::builder::DatasetBuilder::from_uri(clone_uri.to_str().unwrap()) diff --git a/crates/omnigraph/tests/benchmark_scenario_contract.rs b/crates/omnigraph/tests/benchmark_scenario_contract.rs index 197aaf03..3ad9828c 100644 --- a/crates/omnigraph/tests/benchmark_scenario_contract.rs +++ b/crates/omnigraph/tests/benchmark_scenario_contract.rs @@ -407,6 +407,15 @@ fn branch_controls_reuse_phased_isolation_and_verify_exact_branch_views() { } assert!(source.contains("args.branches == 0 || args.tables == 0 || args.runs == 0")); assert!(source.contains("if args.baseline")); + for scenario in [ + "branch-pointer-adopt-lazy", + "branch-pointer-adopt-owned", + "branch-first-write", + "branch-cleanup", + ] { + assert!(source.contains(scenario)); + assert!(harness.contains(scenario)); + } let operation = source .split_once("pub(super) async fn operation") .unwrap() @@ -421,6 +430,9 @@ fn branch_controls_reuse_phased_isolation_and_verify_exact_branch_views() { "db.branch_create_from(", "db.branch_list()", "db.branch_delete(TARGET)", + "db.branch_merge(SOURCE, TARGET)", + "db.load(TARGET, &payload, LoadMode::Append)", + "db.cleanup(CleanupPolicyOptions", ] { assert!(operation.find(call).unwrap() > timer); } @@ -429,9 +441,9 @@ fn branch_controls_reuse_phased_isolation_and_verify_exact_branch_views() { assert!(operation.contains("\"completed_operations\": 1")); assert!(operation.contains("\"rss_boundary\"")); let acknowledgement = operation.find("let operation_wall_us").unwrap(); - let reclaim_join = operation.find("db.wait_for_fork_reclaims().await").unwrap(); let completion = operation.find("let operation_complete_wall_us").unwrap(); - assert!(acknowledgement < reclaim_join && reclaim_join < completion); + assert!(acknowledgement < completion); + assert!(!operation.contains("wait_for_fork_reclaims")); assert!(operation.contains("\"post_ack_reclaim_wait_us\"")); assert!(operation.contains("\"operation_complete_wall_us\"")); let prewarm = operation @@ -447,8 +459,19 @@ fn branch_controls_reuse_phased_isolation_and_verify_exact_branch_views() { > operation.find("let operation_wall_us").unwrap() ); assert!(source.contains("created.tables, fixture.branches[parent].tables")); - assert!(source.contains("!refs.contains_key(native_ref)")); - assert!(source.contains("assert_eq!(verified_reclaimed_table_refs, args.tables)")); + assert!( + source + .split_whitespace() + .collect::() + .contains("assert_eq!(io.data_writes,0,") + ); + assert!(source.contains("same_registration(entry)")); + assert!(source.contains("verify_required_parent(db, fixture).await")); + assert!(source.contains("verified_exact_rows")); + assert!(source.contains("verified_post_adopt_isolation")); + assert!(source.contains("refs.contains_key(native_ref)")); + assert!(!source.contains("!refs.contains_key(native_ref)")); + assert!(source.contains("assert_eq!(verified_deferred_table_refs, args.tables)")); let compact = source .chars() .filter(|ch| !ch.is_whitespace()) @@ -508,8 +531,8 @@ fn branch_controls_reuse_phased_isolation_and_verify_exact_branch_views() { assert!(age.contains("after.checked_sub(before)")); assert!(age.contains("Some(args.history_commits)")); assert!(age.contains("verify_fixture_row(&table, \"base\", 0, args.dims, args.seed)")); - assert!(age.contains("db.wait_for_fork_reclaims().await")); - assert!(age.contains("retired native fork was not reclaimed")); + assert!(!age.contains("wait_for_fork_reclaims")); + assert!(age.contains("retired native fork must remain until cleanup")); assert!(age.contains("retirement must not publish on main")); assert!(!age.contains("Dataset::write")); for field in [ @@ -519,6 +542,7 @@ fn branch_controls_reuse_phased_isolation_and_verify_exact_branch_views() { "setup_age_content_verified", "operation_io_manifest_reads", "operation_io_data_reads", + "operation_io_data_writes", "operation_io_boundary", ] { assert!(aging.contains(field), "missing age/IO evidence {field}"); diff --git a/crates/omnigraph/tests/branching.rs b/crates/omnigraph/tests/branching.rs index cbe90881..9822a024 100644 --- a/crates/omnigraph/tests/branching.rs +++ b/crates/omnigraph/tests/branching.rs @@ -1858,11 +1858,9 @@ async fn branch_merge_applies_node_insert_to_main() { /// Rust because the pins are native table versions, the target ref's physical /// HEAD, and the entry retained on an empty delta; the row-visible half is -/// `merge_adopt_*.gqt`. The lazy iteration stops after its reads (RFC 0062, decision log 2026-09-08). +/// `merge_adopt_*.gqt`. Both named targets adopt the exact source registration. #[tokio::test] async fn branch_merge_preserves_state_when_native_versions_differ() { - // The route ignores version order; the lazy arm adds recovery-owned - // first-touch forking without changing the indexed schema. for lazy_target in [false, true] { assert_native_version_case(8, lazy_target).await; } @@ -1919,11 +1917,6 @@ fn assert_native_version_case( ) .await .unwrap(); - // Inherit main's root-owned indexed table. Cloning feature's - // already-cloned indexes would hit the separate Lance #7840 - // bug pinned by second_generation_branch_index_reads_fail_upstream - // in lance_surface_guards.rs. This first-generation fork still - // exercises the merge's lazy-target recovery route. main.branch_create_from(ReadTarget::branch("main"), "child") .await .unwrap(); @@ -1943,7 +1936,6 @@ fn assert_native_version_case( .unwrap(); ("main", "feature") }; - let target_native = graph_native_ref(uri, target).await; let source_entry = snapshot_branch(&main, source) .await .unwrap() @@ -1976,10 +1968,7 @@ fn assert_native_version_case( source_entry.native_dataset_branch, target_entry.native_dataset_branch ); - assert_eq!( - target_entry.native_dataset_branch.as_deref() == Some(target_native.as_str()), - !lazy_target - ); + assert_eq!(target_entry.native_dataset_branch.is_some(), !lazy_target); assert_eq!( main.branch_merge(source, target).await.unwrap(), MergeOutcome::FastForward @@ -1990,18 +1979,16 @@ fn assert_native_version_case( .dataset("node:Person") .unwrap() .clone(); - let expected_ref = if lazy_target { - Some(target_native.as_str()) - } else { - source_entry.native_dataset_branch.as_deref() - }; assert_eq!( ( merged_entry.published_dataset_version, merged_entry.native_dataset_branch.as_deref() ), - (source_entry.published_dataset_version, expected_ref), - "{target}, {branch_updates} updates: the adopt registers the source's version, as a pointer switch onto the source ref or a fork onto the target's own ref, ordered by the manifest clock (RFC 0062)" + ( + source_entry.published_dataset_version, + source_entry.native_dataset_branch.as_deref() + ), + "{target}, {branch_updates} updates: adoption preserves the exact source ref and version" ); let reopened = Omnigraph::open(uri).await.unwrap(); for handle in [&main, &reopened] { @@ -2073,7 +2060,7 @@ fn assert_native_version_case( .clone(); let table_uri = format!("{uri}/{}", before_empty.dataset_path); let head_before = - open_dataset_head(&table_uri, before_empty.native_dataset_branch.as_deref()) + open_dataset_head_exact(&table_uri, before_empty.native_dataset_branch.as_deref()) .await .version() .version; @@ -2106,7 +2093,7 @@ fn assert_native_version_case( ); } assert_eq!( - open_dataset_head(&table_uri, before_empty.native_dataset_branch.as_deref()) + open_dataset_head_exact(&table_uri, before_empty.native_dataset_branch.as_deref()) .await .version() .version, @@ -2121,22 +2108,31 @@ fn assert_native_version_case( }) .await .unwrap(); - let error = main - .mutate( - target, - MUTATION_QUERIES, - "set_age", - &mixed_params(&[("$name", "Alice")], &[("$age", 51)]), - ) - .await - .unwrap_err(); - assert!( - error.to_string().contains("detached native lineage"), - "{target}, {branch_updates} updates: the pointer switch detached the target's former ref and its borrower still pins it, so the owner's next write must refuse instead of recreating the ref: {error}" + main.mutate( + target, + MUTATION_QUERIES, + "set_age", + &mixed_params(&[("$name", "Alice")], &[("$age", 51)]), + ) + .await + .unwrap(); + let written = snapshot_branch(&main, target).await.unwrap(); + let written = written.dataset("node:Person").unwrap(); + assert_ne!( + written.native_dataset_branch, + before_empty.native_dataset_branch + ); + assert_ne!( + written.native_dataset_branch, + target_entry.native_dataset_branch ); let reopened = Omnigraph::open(uri).await.unwrap(); for handle in [&main, &reopened] { - for (branch, age) in [("borrower", 39 + branch_updates as i32), (target, 50)] { + for (branch, age) in [ + ("borrower", 39 + branch_updates as i32), + (target, 51), + ("main", 50), + ] { let result = handle .query( ReadTarget::branch(branch), @@ -2155,7 +2151,7 @@ fn assert_native_version_case( .unwrap() .value(0), age, - "{branch}: cleanup and the refused write must leave every pin readable" + "{branch}: cleanup and a fresh target fork preserve independent branch values" ); } } @@ -2163,7 +2159,7 @@ fn assert_native_version_case( } #[tokio::test] -async fn branch_write_refuses_detached_native_lineage_before_arming() { +async fn branch_write_after_adoption_keeps_borrowers_and_uses_fresh_forks() { let dir = tempfile::tempdir().unwrap(); let db = init_and_load(&dir).await; db.branch_create("feature").await.unwrap(); @@ -2205,7 +2201,7 @@ async fn branch_write_refuses_detached_native_lineage_before_arming() { let owner_before = snapshot_branch(&db, "feature").await.unwrap(); let table_uri = format!("{}/{}", db.uri(), borrowed.dataset_path); let native = borrowed.native_dataset_branch.as_deref().unwrap(); - let head_before = open_dataset_head(&table_uri, Some(native)) + let head_before = open_dataset_head_exact(&table_uri, Some(native)) .await .version() .version; @@ -2216,33 +2212,30 @@ async fn branch_write_refuses_detached_native_lineage_before_arming() { .await .unwrap(); - let error = db - .load_as( - "feature", - None, - r#"{"type":"Company","data":{"name":"NewCo"}}"#, - LoadMode::Merge, - None, - ) - .await - .unwrap_err(); - assert!( - matches!(&error, OmniError::Manifest(error) - if error.kind == ManifestErrorKind::BadRequest), - "{error}" - ); - assert!( - error.to_string().contains("detached native lineage"), - "{error}" + db.load_as( + "feature", + None, + r#"{"type":"Company","data":{"name":"NewCo"}}"#, + LoadMode::Merge, + None, + ) + .await + .unwrap(); + let owner_after = snapshot_branch(&db, "feature").await.unwrap(); + let written = owner_after.dataset("node:Company").unwrap(); + assert_ne!( + written.native_dataset_branch, + borrowed.native_dataset_branch ); - assert!(error.to_string().contains("Create a new branch"), "{error}"); - let recovery_dir = dir.path().join("__recovery"); - assert!( - !recovery_dir.exists() || fs::read_dir(recovery_dir).unwrap().next().is_none(), - "refusal must precede durable recovery intent" + assert_ne!( + written.native_dataset_branch, + owner_before + .dataset("node:Company") + .unwrap() + .native_dataset_branch ); assert_eq!( - open_dataset_head(&table_uri, Some(native)) + open_dataset_head_exact(&table_uri, Some(native)) .await .version() .version, @@ -2257,7 +2250,7 @@ async fn branch_write_refuses_detached_native_lineage_before_arming() { assert_eq!( branches_after.get(native).unwrap().identifier, branches_before.get(native).unwrap().identifier, - "native ref must not be recreated" + "a fresh write must not recreate the borrowed native ref" ); db.branch_create_from(ReadTarget::branch("feature"), "replacement") .await @@ -2265,22 +2258,86 @@ async fn branch_write_refuses_detached_native_lineage_before_arming() { db.load_as( "replacement", None, - r#"{"type":"Company","data":{"name":"NewCo"}}"#, + r#"{"type":"Company","data":{"name":"ReplacementCo"}}"#, LoadMode::Merge, None, ) .await .unwrap(); - let merge_error = db.branch_merge("replacement", "feature").await.unwrap_err(); - assert!( - merge_error.to_string().contains("detached native lineage"), - "{merge_error}" + let replacement = snapshot_branch(&db, "replacement").await.unwrap(); + let replacement_entry = replacement.dataset("node:Company").unwrap(); + assert_eq!( + db.branch_merge("replacement", "feature").await.unwrap(), + MergeOutcome::FastForward ); - let recovery_dir = dir.path().join("__recovery"); + let adopted = snapshot_branch(&db, "feature").await.unwrap(); + let adopted_entry = adopted.dataset("node:Company").unwrap(); + assert_eq!( + ( + &adopted_entry.type_key, + &adopted_entry.dataset_path, + adopted_entry.published_dataset_version, + &adopted_entry.native_dataset_branch, + adopted_entry.entity_count, + ), + ( + &replacement_entry.type_key, + &replacement_entry.dataset_path, + replacement_entry.published_dataset_version, + &replacement_entry.native_dataset_branch, + replacement_entry.entity_count, + ), + "adoption must preserve the exact replacement table pointer" + ); + db.load_as( + "feature", + None, + r#"{"type":"Company","data":{"name":"AfterAdoptCo"}}"#, + LoadMode::Merge, + None, + ) + .await + .unwrap(); + let after_adopt_write = snapshot_branch(&db, "feature").await.unwrap(); + let after_adopt_entry = after_adopt_write.dataset("node:Company").unwrap(); + assert_ne!( + after_adopt_entry.native_dataset_branch, + replacement_entry.native_dataset_branch + ); + assert_ne!( + after_adopt_entry.native_dataset_branch, + written.native_dataset_branch + ); + let replacement_after = snapshot_branch(&db, "replacement").await.unwrap(); assert!( - !recovery_dir.exists() || fs::read_dir(recovery_dir).unwrap().next().is_none(), - "merge refusal must precede durable recovery intent" + replacement_after + .dataset("node:Company") + .unwrap() + .same_registration(replacement_entry) ); + let reopened = Omnigraph::open(db.uri()).await.unwrap(); + for handle in [&db, &reopened] { + for branch in ["child", "main", "replacement", "feature"] { + let names = collect_column_strings( + &read_table_branch(handle, branch, "node:Company").await, + "name", + ); + assert_eq!( + names.iter().any(|name| name == "AfterAdoptCo"), + branch == "feature" + ); + assert_eq!( + names.iter().any(|name| name == "ReplacementCo"), + matches!(branch, "replacement" | "feature") + ); + assert_eq!( + names.iter().any(|name| name == "NewCo"), + matches!(branch, "replacement" | "feature") + ); + assert_eq!(names.iter().any(|name| name == "MainCo"), branch != "child"); + assert!(names.iter().any(|name| name == "BorrowedCo")); + } + } let child = snapshot_branch(&db, "child").await.unwrap(); let child = child.dataset("node:Company").unwrap(); assert_eq!(child.native_dataset_branch, borrowed.native_dataset_branch); @@ -2288,7 +2345,7 @@ async fn branch_write_refuses_detached_native_lineage_before_arming() { child.published_dataset_version, borrowed.published_dataset_version ); - assert_eq!( + assert_ne!( snapshot_branch(&db, "feature") .await .unwrap() @@ -3002,17 +3059,16 @@ async fn ensure_indices_on_child_branch_keeps_inherited_table_when_no_work_is_ne experiment.ensure_indices_on("experiment").await.unwrap(); let experiment_snap = snapshot_branch(&experiment, "experiment").await.unwrap(); - assert!( - is_incarnation_of( - experiment_snap - .dataset("node:Person") - .unwrap() - .native_dataset_branch - .as_deref() - .unwrap_or(""), - "feature" - ), - "index reconciliation must not manufacture a ref-only first-touch effect" + assert_eq!( + experiment_snap + .dataset("node:Person") + .unwrap() + .native_dataset_branch, + experiment_inherited + .dataset("node:Person") + .unwrap() + .native_dataset_branch, + "index reconciliation must preserve the exact inherited table ref when no work is needed" ); assert_eq!( experiment_snap @@ -3164,6 +3220,8 @@ async fn branch_merge_into_non_main_target_works() { .into_iter() .map(|commit| commit.graph_commit_id) .collect::>(); + let source_before = snapshot_branch(&feature, "feature").await.unwrap(); + let source_entry = source_before.dataset("node:Person").unwrap(); let outcome = main.branch_merge("feature", "experiment").await.unwrap(); assert_eq!(outcome, MergeOutcome::FastForward); assert_eq!( @@ -3206,13 +3264,23 @@ async fn branch_merge_into_non_main_target_works() { .unwrap(); assert_eq!(eve.num_rows(), 1); let experiment_snap = snapshot_branch(&experiment, "experiment").await.unwrap(); - helpers::assert_native_branch_of( - experiment_snap - .dataset("node:Person") - .unwrap() - .native_dataset_branch - .as_deref(), - "experiment", + let adopted_entry = experiment_snap.dataset("node:Person").unwrap(); + assert_eq!( + ( + &adopted_entry.type_key, + &adopted_entry.dataset_path, + adopted_entry.published_dataset_version, + &adopted_entry.native_dataset_branch, + adopted_entry.entity_count, + ), + ( + &source_entry.type_key, + &source_entry.dataset_path, + source_entry.published_dataset_version, + &source_entry.native_dataset_branch, + source_entry.entity_count, + ), + "the named target must adopt the exact source table pointer" ); let mut reopened_main = Omnigraph::open(uri).await.unwrap(); @@ -3491,7 +3559,7 @@ async fn branch_api_rejects_reserved_main_and_same_source_target_merge() { } #[tokio::test] -async fn branch_delete_removes_owned_table_branches_and_allows_recreate() { +async fn branch_delete_defers_owned_fork_cleanup_and_allows_recreate() { let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap(); let mut main = init_and_load(&dir).await; @@ -3508,25 +3576,35 @@ async fn branch_delete_removes_owned_table_branches_and_allows_recreate() { .await .unwrap(); - let first_native = graph_native_ref(uri, "feature").await; - assert!( - is_incarnation_of(&first_native, "feature") && first_native != "feature", - "an engine-created branch owns an incarnation-suffixed native ref, got '{first_native}'" - ); - + let first_fork = snapshot_branch(&main, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); + let person_path = snapshot_branch(&main, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .dataset_path + .clone(); + let person_uri = format!("{uri}/{person_path}"); main.branch_delete("feature").await.unwrap(); assert_eq!(main.branch_list().await.unwrap(), vec!["main"]); - // Join the background fork reclaim so the recreate below starts from a - // physically clean namespace (it would otherwise serialize behind the - // reclaim's gates and self-heal any leftover orphan). - main.wait_for_fork_reclaims().await; - - main.branch_create("feature").await.unwrap(); - let second_native = graph_native_ref(uri, "feature").await; - assert_ne!( - first_native, second_native, - "a recreated branch mints a new incarnation; it never reuses the dead one's path" + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!( + branches.contains_key(&first_fork), + "delete defers physical fork collection" ); + main.branch_create("feature").await.unwrap(); mutate_branch( &mut main, "feature", @@ -3536,23 +3614,51 @@ async fn branch_delete_removes_owned_table_branches_and_allows_recreate() { ) .await .unwrap(); - + let second_fork = snapshot_branch(&main, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); + assert_ne!(first_fork, second_fork); assert_eq!(count_rows_branch(&main, "feature", "node:Person").await, 5); - let recreated = snapshot_branch(&main, "feature").await.unwrap(); - assert_eq!( - recreated - .dataset("node:Person") - .unwrap() - .native_dataset_branch - .as_deref(), - Some(second_native.as_str()), - "the recreated branch's fork is named by its own incarnation" + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!( + branches.contains_key(&first_fork), + "recreated writes leave the former fork intact" ); + assert!(branches.contains_key(&second_fork)); + main.cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!(!branches.contains_key(&first_fork)); + assert!( + branches.contains_key(&second_fork), + "cleanup preserves the recreated branch's fork" + ); + drop(feature); + let reopened = Omnigraph::open(uri).await.unwrap(); assert_eq!( - main.branch_list().await.unwrap(), - vec!["main", "feature"], - "branch listing shows logical names only" + count_rows_branch(&reopened, "feature", "node:Person").await, + 5 ); + assert_eq!(main.branch_list().await.unwrap(), vec!["main", "feature"]); } #[tokio::test] @@ -3604,29 +3710,54 @@ async fn branch_namespace_rejects_live_physical_path_prefix_collisions() { "prefix admission must reject before Lance creates the target clone" ); + let manifest = lance::Dataset::open(&format!("{}/__manifest", dir.path().display())) + .await + .unwrap(); + let ancestor_native = helpers::native_ref_for(&manifest, "feature").await.unwrap(); + let ancestor = manifest.branches().get(&ancestor_native).await.unwrap(); db.branch_delete("feature").await.unwrap(); db.branch_create("feature/child").await.unwrap(); + let refs_before_refusal = manifest.list_branches().await.unwrap(); + assert_eq!( + refs_before_refusal[&ancestor_native].identifier, + ancestor.identifier + ); + let retirement: serde_json::Value = serde_json::from_str( + &refs_before_refusal[&ancestor_native].metadata["omnigraph.retired_manifest_branch"], + ) + .unwrap(); + assert_eq!( + retirement, + serde_json::json!({ + "version": 1, + "native_branch": ancestor_native, + "identifier": ancestor.identifier, + }) + ); 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_eq!( + serde_json::to_value(manifest.list_branches().await.unwrap()).unwrap(), + serde_json::to_value(refs_before_refusal).unwrap(), + "inverse admission refusal must preserve the retired ancestor and create no ref" + ); assert!( - !lance::Dataset::open(&format!("{}/__manifest", dir.path().display())) - .await - .unwrap() - .list_branches() + helpers::native_ref_for(&manifest, "feature") .await - .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), - "inverse admission refusal must not create an ancestor ref" + .is_none() + ); + assert_eq!( + db.branch_list().await.unwrap(), + vec!["main", "feature/child"] ); } #[tokio::test] -async fn branch_delete_refuses_legacy_physical_path_children() { +async fn branch_delete_retires_legacy_physical_path_parents() { let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap(); let db = init_and_load(&dir).await; @@ -3644,36 +3775,26 @@ async fn branch_delete_refuses_legacy_physical_path_children() { .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}" - ); + db.branch_delete("feature").await.unwrap(); assert!( - manifest - .list_branches() + helpers::native_ref_for(&manifest, "feature") .await - .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), - "refusal must not remove ancestor authority" + .is_none() ); - - db.branch_delete("feature/child").await.unwrap(); - db.branch_delete("feature").await.unwrap(); + assert!(manifest.branches().get("feature").await.is_ok()); assert_eq!( - db.branch_list().await.unwrap(), - vec!["main"], - "legacy overlap must converge when deleted leaf-first" + count_rows_branch(&db, "feature/child", "node:Person").await, + 4 ); + db.branch_delete("feature/child").await.unwrap(); + assert_eq!(db.branch_list().await.unwrap(), vec!["main"]); } #[tokio::test] -async fn branch_delete_rejects_branches_still_referenced_by_descendants() { +async fn branch_delete_retires_native_parent_and_cleanup_preserves_live_child() { let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap(); - let main = init_and_load(&dir).await; + let mut main = init_and_load(&dir).await; main.branch_create("feature").await.unwrap(); let mut feature = Omnigraph::open(uri).await.unwrap(); @@ -3691,8 +3812,219 @@ async fn branch_delete_rejects_branches_still_referenced_by_descendants() { .await .unwrap(); - let err = main.branch_delete("feature").await.unwrap_err(); - assert!(err.to_string().contains("still depends on it")); + let manifest = lance::Dataset::open(&format!("{uri}/__manifest")) + .await + .unwrap(); + let live = manifest.list_branches().await.unwrap(); + let native = live + .keys() + .find(|name| helpers::is_incarnation_of(name, "feature")) + .unwrap() + .clone(); + let identifier = live[&native].identifier.clone(); + let retained = manifest.checkout_branch(&native).await.unwrap(); + + let changed_metadata = [("external-owner".to_string(), "preserved".to_string())] + .into_iter() + .collect(); + manifest + .branches() + .replace_metadata(&native, changed_metadata) + .await + .unwrap(); + assert_eq!( + manifest.list_branches().await.unwrap()[&native].metadata["external-owner"], + "preserved" + ); + assert!( + Omnigraph::open(uri) + .await + .unwrap() + .branch_list() + .await + .unwrap() + .contains(&"feature".to_string()) + ); + let tagged_snapshot = snapshot_branch(&main, "feature").await.unwrap(); + let tagged_entry = tagged_snapshot.dataset("node:Person").unwrap(); + manifest + .tags() + .create( + "retirement-pin", + (native.as_str(), retained.version().version), + ) + .await + .unwrap(); + let refused = main.branch_delete("feature").await.expect_err( + "native graph tags retain the deletion fence until graph-wide tagged table pins exist", + ); + assert!( + refused.to_string().contains("native manifest tag"), + "{refused}" + ); + assert_eq!( + manifest.branches().get(&native).await.unwrap().identifier, + identifier + ); + let after_refusal = snapshot_branch(&main, "feature").await.unwrap(); + let current_entry = after_refusal.dataset("node:Person").unwrap(); + assert_eq!( + current_entry.native_dataset_branch, + tagged_entry.native_dataset_branch + ); + assert_eq!( + current_entry.published_dataset_version, + tagged_entry.published_dataset_version + ); + assert_eq!(count_rows_branch(&main, "feature", "node:Person").await, 5); + assert_eq!( + count_rows_branch(&main, "experiment", "node:Person").await, + 5 + ); + manifest.tags().delete("retirement-pin").await.unwrap(); + main.branch_delete("feature").await.unwrap(); + assert!( + helpers::native_ref_for(&manifest, "feature") + .await + .is_none() + ); + assert!( + !main + .branch_list() + .await + .unwrap() + .contains(&"feature".to_string()) + ); + assert_eq!( + manifest.branches().get(&native).await.unwrap().identifier, + identifier + ); + assert_eq!( + manifest.branches().get(&native).await.unwrap().metadata["external-owner"], + "preserved" + ); + assert_eq!(retained.branch_identifier().await.unwrap(), identifier); + let ref_path = dir + .path() + .join("__manifest/_refs/branches") + .join(format!("{native}.json")); + let retired_bytes = std::fs::read(&ref_path).unwrap(); + let retired_contents: serde_json::Value = serde_json::from_slice(&retired_bytes).unwrap(); + let marker = &retired_contents["metadata"]["omnigraph.retired_manifest_branch"]; + let retirement: serde_json::Value = serde_json::from_str(marker.as_str().unwrap()).unwrap(); + assert_eq!(retirement["version"], serde_json::json!(1)); + assert_eq!(retirement["native_branch"], serde_json::json!(native)); + assert_eq!( + retirement["identifier"], + serde_json::to_value(&identifier).unwrap() + ); + for invalid in [ + "missing_identifier", + "identifier", + "version", + "native_branch", + "unknown_field", + ] { + let mut broken_marker = retirement.clone(); + match invalid { + "missing_identifier" => { + broken_marker.as_object_mut().unwrap().remove("identifier"); + } + "version" => broken_marker["version"] = serde_json::json!(99), + "identifier" => { + let foreign = live + .values() + .find(|contents| contents.identifier != identifier) + .unwrap(); + broken_marker["identifier"] = serde_json::to_value(&foreign.identifier).unwrap(); + } + "native_branch" => broken_marker["native_branch"] = serde_json::json!("foreign"), + "unknown_field" => broken_marker["unknown"] = serde_json::json!(true), + _ => unreachable!(), + } + let mut broken = retired_contents.clone(); + broken["metadata"]["omnigraph.retired_manifest_branch"] = + serde_json::json!(serde_json::to_string(&broken_marker).unwrap()); + std::fs::write(&ref_path, serde_json::to_vec(&broken).unwrap()).unwrap(); + assert!(manifest.branches().get(&native).await.is_ok()); + assert!(main.branch_list().await.is_err(), "{invalid}"); + assert!( + snapshot_branch(&main, "feature").await.is_err(), + "{invalid}" + ); + assert!( + main.cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .is_err(), + "{invalid}" + ); + assert!( + ref_path.exists(), + "invalid retirement must preserve physical authority" + ); + assert!(dir.path().join("__manifest/tree").join(&native).exists()); + } + std::fs::write(&ref_path, retired_bytes).unwrap(); + assert!(snapshot_branch(&feature, "feature").await.is_err()); + assert_eq!( + count_rows_branch(&feature, "experiment", "node:Person").await, + 5 + ); + + let mut reopened = Omnigraph::open(uri).await.unwrap(); + reopened.branch_create("feature").await.unwrap(); + assert_eq!( + count_rows_branch(&reopened, "feature", "node:Person").await, + 4 + ); + mutate_branch( + &mut reopened, + "experiment", + MUTATION_QUERIES, + "insert_person", + &mixed_params(&[("$name", "Frank")], &[("$age", 41)]), + ) + .await + .unwrap(); + reopened + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + assert!( + manifest.branches().get(&native).await.is_ok(), + "a live child retains its native ancestor" + ); + assert_eq!( + count_rows_branch(&reopened, "experiment", "node:Person").await, + 6 + ); + reopened.branch_delete("experiment").await.unwrap(); + reopened + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + assert!( + manifest.branches().get(&native).await.is_err(), + "cleanup reclaims unreferenced retired parents" + ); + assert_eq!( + count_rows_branch( + &Omnigraph::open(uri).await.unwrap(), + "feature", + "node:Person" + ) + .await, + 4 + ); } // ─── Step 9b: Surgical merge publish tests ────────────────────────────────── diff --git a/crates/omnigraph/tests/failpoints.rs b/crates/omnigraph/tests/failpoints.rs index 6b28dea3..d060a16d 100644 --- a/crates/omnigraph/tests/failpoints.rs +++ b/crates/omnigraph/tests/failpoints.rs @@ -326,14 +326,9 @@ async fn native_branch_controls_reclassify_lost_acknowledgements() { ); } -// 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 -// object-store error) must NOT fail the call: the branch is already gone, and -// `cleanup` reconciles the stranded fork. The branch name is reusable after. #[tokio::test] #[serial] -async fn branch_delete_partial_failure_converges_via_cleanup() { +async fn branch_delete_cleanup_failure_converges_on_retry() { let _scenario = FailScenario::setup(); let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap().to_string(); @@ -352,68 +347,63 @@ async fn branch_delete_partial_failure_converges_via_cleanup() { .unwrap(); drop(feature); + let former_fork = helpers::snapshot_branch(&main, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); let person_uri = node_table_uri(&main, "Person").await; - { - let ds = lance::Dataset::open(&person_uri).await.unwrap(); - assert!( - ds.list_branches() - .await - .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), - "precondition: the owned table fork exists before delete" - ); - } - - // Inject a failure during per-table cleanup, AFTER the manifest authority - // flip. branch_delete must still succeed (best-effort reclaim). The - // reclaim runs in a background task, so join it while the failpoint is - // still armed. - { - let _fp = ScopedFailPoint::new(names::BRANCH_DELETE_BEFORE_TABLE_CLEANUP, "return"); - main.branch_delete("feature").await.expect( - "branch_delete is best-effort after the manifest flip: a cleanup-step \ - failure must not fail the call", - ); - main.wait_for_fork_reclaims().await; - } - - // Authority flipped: the branch is gone. + main.branch_delete("feature").await.unwrap(); assert_eq!(main.branch_list().await.unwrap(), vec!["main".to_string()]); - - // The eager reclaim failed, so the orphan is stranded until cleanup. + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!( + branches.contains_key(&former_fork), + "delete leaves table forks for cleanup" + ); { - let ds = lance::Dataset::open(&person_uri).await.unwrap(); - assert!( - ds.list_branches() - .await - .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), - "failed eager reclaim should leave the orphan for cleanup to reconcile" - ); + let _fp = ScopedFailPoint::new(names::CLEANUP_RECONCILE_FORK, "return"); + main.cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); } - - // cleanup converges: the orphan is reclaimed. + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!( + branches.contains_key(&former_fork), + "failed cleanup preserves the deferred fork" + ); main.cleanup(omnigraph::db::CleanupPolicyOptions { keep_versions: Some(1), older_than: None, }) .await .unwrap(); - { - let ds = lance::Dataset::open(&person_uri).await.unwrap(); - assert!( - !ds.list_branches() - .await - .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), - "cleanup should reconcile the orphaned fork away" - ); - } + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!( + !branches.contains_key(&former_fork), + "cleanup retry reclaims the unused fork" + ); - // The name is reusable after cleanup reclaims the orphan. main.branch_create("feature").await.unwrap(); let mut feature2 = Omnigraph::open(&uri).await.unwrap(); helpers::mutate_branch( @@ -427,14 +417,9 @@ async fn branch_delete_partial_failure_converges_via_cleanup() { .unwrap(); } -// Reusing a branch name whose delete left an orphaned fork (before `cleanup` -// reconciles it) must SELF-HEAL on the next write — the write reclaims the -// manifest-unreferenced fork and re-forks, rather than wedging with "incomplete -// prior delete; run cleanup". (This test was the inverse before the fork-as- -// idempotent-reconcile fix; its flip is the signal the bug class is closed.) #[tokio::test] #[serial] -async fn recreate_over_orphaned_fork_self_heals_without_cleanup() { +async fn recreate_over_unused_fork_writes_without_cleanup() { let _scenario = FailScenario::setup(); let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap().to_string(); @@ -452,18 +437,24 @@ async fn recreate_over_orphaned_fork_self_heals_without_cleanup() { .await .unwrap(); drop(feature); - let first_native = helpers::graph_native_ref(&uri, "feature").await; - // Partial delete: leaves the Person fork orphaned (cleanup not yet run). - // Join the background reclaim while the failpoint is still armed. - { - let _fp = ScopedFailPoint::new(names::BRANCH_DELETE_BEFORE_TABLE_CLEANUP, "return"); - main.branch_delete("feature").await.unwrap(); - main.wait_for_fork_reclaims().await; - } - - // Recreate the name and write to the previously-forked table WITHOUT a - // cleanup in between. The write must self-heal the stale orphan fork. + let first_fork = helpers::snapshot_branch(&main, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); + let person_uri = node_table_uri(&main, "Person").await; + main.branch_delete("feature").await.unwrap(); + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!(branches.contains_key(&first_fork)); main.branch_create("feature").await.unwrap(); let mut feature2 = Omnigraph::open(&uri).await.unwrap(); helpers::mutate_branch( @@ -474,26 +465,20 @@ async fn recreate_over_orphaned_fork_self_heals_without_cleanup() { &mixed_params(&[("$name", "Frank")], &[("$age", 41)]), ) .await - .expect("recreate-over-orphan write must self-heal, not require cleanup"); - - // The recreated branch forks FRESH from main: the deleted branch's Eve is - // gone and only the new Frank is added on top of main's seed. A count of - // main + 2 would mean Eve resurrected from the stale fork (the bug). - let main_people = helpers::count_rows(&main, "node:Person").await; - let feature_people = helpers::count_rows_branch(&feature2, "feature", "node:Person").await; + .expect("a new branch writes while the former fork awaits cleanup"); assert_eq!( - feature_people, - main_people + 1, - "self-healed feature must fork fresh from main (+Frank only); \ - main={main_people}, feature={feature_people} (main+2 ⇒ Eve resurrected)" + helpers::count_rows_branch(&feature2, "feature", "node:Person").await, + helpers::count_rows(&main, "node:Person").await + 1, ); - - // The recreated branch forks under its own incarnation. The dead - // incarnation's fork sits on a path nothing resolves to: unreferenced - // garbage until cleanup, never healed in place and never read. - let second_native = helpers::graph_native_ref(&uri, "feature").await; - assert_ne!(first_native, second_native); - let person_uri = node_table_uri(&main, "Person").await; + let second_fork = helpers::snapshot_branch(&feature2, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); + assert_ne!(first_fork, second_fork); let branches = lance::Dataset::open(&person_uri) .await .unwrap() @@ -501,13 +486,10 @@ async fn recreate_over_orphaned_fork_self_heals_without_cleanup() { .await .unwrap(); assert!( - branches.contains_key(&first_native), - "the dead incarnation's fork remains as garbage until cleanup" - ); - assert!( - branches.contains_key(&second_native), - "the recreated branch forked under its new incarnation" + branches.contains_key(&first_fork), + "writes do not collect the former fork" ); + assert!(branches.contains_key(&second_fork)); main.cleanup(omnigraph::db::CleanupPolicyOptions { keep_versions: Some(1), older_than: None, @@ -520,24 +502,26 @@ async fn recreate_over_orphaned_fork_self_heals_without_cleanup() { .list_branches() .await .unwrap(); + assert!(!branches.contains_key(&first_fork)); assert!( - !branches.contains_key(&first_native), - "cleanup reclaims the dead incarnation's fork" + branches.contains_key(&second_fork), + "cleanup preserves the live fork" + ); + drop(feature2); + let reopened = Omnigraph::open(&uri).await.unwrap(); + assert_eq!( + helpers::count_rows_branch(&reopened, "feature", "node:Person").await, + 5 ); - assert!(branches.contains_key(&second_native)); } -// branch_delete acknowledges at the manifest authority flip while a rendezvous -// callback parks the fork reclaim: the owned fork observably survives the -// response, and releasing the rendezvous + `wait_for_fork_reclaims` converges -// it. A response that waited for reclaim would deadlock against the callback. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn branch_delete_acknowledges_before_fork_reclaim_completes() { +async fn branch_delete_acknowledges_with_forks_awaiting_cleanup() { let _scenario = FailScenario::setup(); let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap().to_string(); - let main = helpers::init_and_load(&dir).await; + let mut main = helpers::init_and_load(&dir).await; main.branch_create("feature").await.unwrap(); let mut feature = Omnigraph::open(&uri).await.unwrap(); @@ -552,66 +536,57 @@ async fn branch_delete_acknowledges_before_fork_reclaim_completes() { .unwrap(); drop(feature); + let former_fork = helpers::snapshot_branch(&main, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); let person_uri = node_table_uri(&main, "Person").await; - let feature_native = helpers::graph_native_ref(&uri, "feature").await; - - let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>(); - let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); - let entered_tx = std::sync::Mutex::new(entered_tx); - let release_rx = std::sync::Mutex::new(release_rx); { - let _fp = - ScopedFailPoint::with_callback(names::BRANCH_DELETE_BEFORE_TABLE_CLEANUP, move || { - let _ = entered_tx.lock().unwrap().send(()); - let _ = release_rx.lock().unwrap().recv(); - }); - + let _fp = ScopedFailPoint::new(names::CLEANUP_RECONCILE_FORK, "panic"); main.branch_delete("feature").await.unwrap(); - - // The authority flip is visible at return. assert_eq!(main.branch_list().await.unwrap(), vec!["main".to_string()]); - - // The background reclaim reaches the rendezvous and parks there; the - // owned fork still exists, so the response did not wait for reclaim. - entered_rx - .recv_timeout(std::time::Duration::from_secs(30)) - .expect("background fork reclaim should reach the rendezvous"); - { - let ds = lance::Dataset::open(&person_uri).await.unwrap(); - assert!( - ds.list_branches() - .await - .unwrap() - .contains_key(&feature_native), - "the owned fork must still exist while the reclaim is parked: \ - the response precedes physical reclaim" - ); - } - - release_tx.send(()).unwrap(); - main.wait_for_fork_reclaims().await; } - - let ds = lance::Dataset::open(&person_uri).await.unwrap(); + let reopened = Omnigraph::open(&uri).await.unwrap(); + assert_eq!( + reopened.branch_list().await.unwrap(), + vec!["main".to_string()] + ); + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); assert!( - !ds.list_branches() - .await - .unwrap() - .contains_key(&feature_native), - "joining the background reclaim must converge the owned fork away" + branches.contains_key(&former_fork), + "delete and reopen defer table-fork collection" ); + main.cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!(!branches.contains_key(&former_fork)); } -// The background fork reclaim holds the request's schema, branch, and table -// gates until it settles, so a same-name recreate serializes behind it and -// can never race the fork removal. #[tokio::test(flavor = "multi_thread", worker_threads = 3)] #[serial] -async fn branch_recreate_serializes_behind_background_fork_reclaim() { +async fn branch_recreate_completes_while_old_forks_await_cleanup() { let _scenario = FailScenario::setup(); let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap().to_string(); - let main = helpers::init_and_load(&dir).await; + let mut main = helpers::init_and_load(&dir).await; main.branch_create("feature").await.unwrap(); let mut feature = Omnigraph::open(&uri).await.unwrap(); @@ -626,69 +601,104 @@ async fn branch_recreate_serializes_behind_background_fork_reclaim() { .unwrap(); drop(feature); - // Open the racing handle before the delete so its open-time recovery - // sweep cannot interact with the parked reclaim's gates. + let first_fork = helpers::snapshot_branch(&main, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); + let person_uri = node_table_uri(&main, "Person").await; let racer = Omnigraph::open(&uri).await.unwrap(); - - let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>(); - let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); - let entered_tx = std::sync::Mutex::new(entered_tx); - let release_rx = std::sync::Mutex::new(release_rx); - { - let _fp = - ScopedFailPoint::with_callback(names::BRANCH_DELETE_BEFORE_TABLE_CLEANUP, move || { - let _ = entered_tx.lock().unwrap().send(()); - let _ = release_rx.lock().unwrap().recv(); - }); - - main.branch_delete("feature").await.unwrap(); - entered_rx - .recv_timeout(std::time::Duration::from_secs(30)) - .expect("background fork reclaim should reach the rendezvous"); - - // While the reclaim is parked with the gates held, a same-name - // recreate must wait, not complete. - let mut create = + main.branch_delete("feature").await.unwrap(); + let mut racer = { + let _fp = ScopedFailPoint::new(names::CLEANUP_RECONCILE_FORK, "panic"); + let create = tokio::spawn(async move { racer.branch_create("feature").await.map(|()| racer) }); - let parked = tokio::time::timeout(std::time::Duration::from_millis(300), &mut create).await; - assert!( - parked.is_err(), - "branch_create must serialize behind the in-flight fork reclaim's gates" - ); - - release_tx.send(()).unwrap(); - main.wait_for_fork_reclaims().await; - - // With the reclaim settled and its gates released, the recreate - // completes against a clean namespace. - let racer = create + tokio::time::timeout(std::time::Duration::from_secs(30), create) .await + .expect("recreate must complete without waiting for table-fork cleanup") .expect("recreate task must not panic") - .expect("recreate must succeed after the reclaim settles"); - assert!( - racer - .branch_list() - .await - .unwrap() - .contains(&"feature".to_string()), - "the recreated branch must be visible" - ); - } + .unwrap() + }; + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!(branches.contains_key(&first_fork)); + helpers::mutate_branch( + &mut racer, + "feature", + MUTATION_QUERIES, + "insert_person", + &mixed_params(&[("$name", "Frank")], &[("$age", 41)]), + ) + .await + .unwrap(); + let second_fork = helpers::snapshot_branch(&racer, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); + assert_ne!(first_fork, second_fork); + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!(branches.contains_key(&first_fork)); + assert!(branches.contains_key(&second_fork)); + main.cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!(!branches.contains_key(&first_fork)); + assert!(branches.contains_key(&second_fork)); + assert_eq!( + helpers::count_rows_branch(&racer, "feature", "node:Person").await, + 5 + ); +} + +fn saved_sidecar_table_fork(root: &std::path::Path, operation_id: &str, table_key: &str) -> String { + let path = root.join("__recovery").join(format!("{operation_id}.json")); + let sidecar: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + let pins = sidecar["tables"].as_array().unwrap(); + let matching = pins + .iter() + .filter(|pin| pin["table_key"].as_str() == Some(table_key)) + .collect::>(); + assert_eq!( + matching.len(), + 1, + "sidecar must contain one exact table pin" + ); + matching[0]["table_branch"].as_str().unwrap().to_string() } -// The write-path orphan reclaim shares the same fresh-authority classifier as -// cleanup. If that classifier is Indeterminate after v3 recovery has been -// armed, the write must retain recovery ownership and return RecoveryRequired; -// deleting the sidecar would lose authority over a target ref that may already -// have been created. A full read-write reopen resolves the armed no-HEAD-effect -// attempt before the write can be retried. #[tokio::test] #[serial] -async fn recreate_over_orphaned_fork_reports_indeterminate_authority_read() { +async fn fresh_fork_write_ignores_unavailable_cleanup_classifier() { 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 mut db = helpers::init_and_load(&dir).await; db.branch_create("feature").await.unwrap(); let person_uri = node_table_uri(&db, "Person").await; @@ -699,48 +709,73 @@ async fn recreate_over_orphaned_fork_reports_indeterminate_authority_read() { ds.create_branch(&feature_native, base, None).await.unwrap(); } + let orphan_identifier = lance::Dataset::open(&person_uri) + .await + .unwrap() + .checkout_branch(&feature_native) + .await + .unwrap() + .branch_identifier() + .await + .unwrap(); let row = r#"{"type":"Person","data":{"name":"Grace","age":37}}"#; { let _fp = ScopedFailPoint::new(names::CLASSIFY_FRESH_READ, "return"); - let err = db - .load_as("feature", None, row, LoadMode::Merge, None) + db.load_as("feature", None, row, LoadMode::Merge, None) .await - .expect_err("indeterminate authority read must fail retryably"); - - assert!( - matches!(err, OmniError::RecoveryRequired { .. }), - "an ambiguous post-arm fork error must retain recovery ownership: {err:?}" - ); - let message = err.to_string(); - assert!( - message.contains("could not verify") - && message.contains("fresh manifest authority was unavailable") - && message.contains("refresh and retry"), - "error should name the unavailable authority read, got: {message}" - ); - assert!( - !message.contains("expected published dataset version"), - "indeterminate authority must not be reported as a version mismatch: {message}" - ); - - let ds = lance::Dataset::open(&person_uri).await.unwrap(); - assert!( - ds.list_branches() - .await - .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), - "ambiguous orphan status must leave the fork for a later retry" - ); + .expect("fresh writes do not require cleanup classification"); } - - drop(db); - let db = Omnigraph::open(&uri) + assert!(helpers::recovery::sidecar_operation_ids(dir.path()).is_empty()); + let published = helpers::snapshot_branch(&db, "feature").await.unwrap(); + let live_fork = published + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); + assert_ne!(live_fork, feature_native); + let root_dataset = lance::Dataset::open(&person_uri).await.unwrap(); + assert_eq!( + root_dataset + .checkout_branch(&feature_native) + .await + .unwrap() + .branch_identifier() + .await + .unwrap(), + orphan_identifier + ); + assert!( + root_dataset + .list_branches() + .await + .unwrap() + .contains_key(&live_fork) + ); + assert_eq!( + helpers::count_rows_branch(&db, "feature", "node:Person").await, + 5 + ); + db.cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + let branches = lance::Dataset::open(&person_uri) .await - .expect("read-write reopen must resolve the retained armed sidecar"); - db.load_as("feature", None, row, LoadMode::Merge, None) + .unwrap() + .list_branches() .await - .expect("after recovery, fresh authority reclaims the orphan and the write converges"); + .unwrap(); + assert!(!branches.contains_key(&feature_native)); + assert!(branches.contains_key(&live_fork)); + drop(db); + let reopened = Omnigraph::open(&uri).await.unwrap(); + assert_eq!( + helpers::count_rows_branch(&reopened, "feature", "node:Person").await, + 5 + ); } // cleanup is the guaranteed convergence backstop, so one table's transient @@ -1042,7 +1077,9 @@ async fn cross_handle_reclaim_never_deletes_live_intent_owned_fork() { rendezvous.wait_until_reached().await; let person_uri = node_table_uri(&db_b, "Person").await; - let fork_before = helpers::open_dataset_head(&person_uri, Some("feature")) + let operation_id = single_sidecar_operation_id(dir.path()); + let table_fork = saved_sidecar_table_fork(dir.path(), &operation_id, "node:Person"); + let fork_before = helpers::open_dataset_head_exact(&person_uri, Some(&table_fork)) .await .branch_identifier() .await @@ -1066,7 +1103,7 @@ async fn cross_handle_reclaim_never_deletes_live_intent_owned_fork() { "the second handle must wait on A's root-scoped effect gates" ); - let fork_after = helpers::open_dataset_head(&person_uri, Some("feature")) + let fork_after = helpers::open_dataset_head_exact(&person_uri, Some(&table_fork)) .await .branch_identifier() .await @@ -1080,7 +1117,7 @@ async fn cross_handle_reclaim_never_deletes_live_intent_owned_fork() { writer_a .await .unwrap() - .expect("A must commit normally after the competing reclaim is rejected"); + .expect("A must commit normally after the competing writer waits"); writer_b .await .unwrap() @@ -1088,6 +1125,24 @@ async fn cross_handle_reclaim_never_deletes_live_intent_owned_fork() { drop(db_a); let mut db = Omnigraph::open(&uri).await.unwrap(); + let published = helpers::snapshot_branch(&db, "feature").await.unwrap(); + assert_eq!( + published + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .as_deref(), + Some(table_fork.as_str()) + ); + assert_eq!( + helpers::open_dataset_head_exact(&person_uri, Some(&table_fork)) + .await + .branch_identifier() + .await + .unwrap(), + fork_before, + "both writers must finish on the original durable fork incarnation" + ); assert_eq!( helpers::count_rows_branch(&db, "feature", "node:Person").await, 5, @@ -1133,7 +1188,7 @@ async fn armed_first_touch_recovery_accepts_missing_target_ref() { } let operation_id = single_sidecar_operation_id(dir.path()); let person_uri = node_table_uri(&db, "Person").await; - let feature_native = helpers::graph_native_ref(dir.path().to_str().unwrap(), "feature").await; + let feature_native = saved_sidecar_table_fork(dir.path(), &operation_id, "node:Person"); assert!( !lance::Dataset::open(&person_uri) .await @@ -1141,8 +1196,7 @@ async fn armed_first_touch_recovery_accepts_missing_target_ref() { .list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), + .contains_key(&feature_native), "precondition: crash happened before the target ref was created" ); @@ -1238,7 +1292,7 @@ async fn armed_first_touch_recovery_accepts_missing_target_ref() { ); drop(db); - let recovered = Omnigraph::open(&uri).await.unwrap(); + let mut recovered = Omnigraph::open(&uri).await.unwrap(); assert_eq!( helpers::count_rows_branch(&recovered, "feature", "node:Person").await, main_rows, @@ -1252,17 +1306,30 @@ async fn armed_first_touch_recovery_accepts_missing_target_ref() { "full recovery must remove the empty armed intent" ); assert!( - !std::path::Path::new(&person_uri) + std::path::Path::new(&person_uri) .join("tree") .join(&feature_native) .exists(), - "full recovery must reclaim an unlisted clone-only table fork" + "full recovery defers clone-only table-fork collection" + ); + recovered + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + assert!( + !std::path::Path::new(&person_uri) + .join("tree") + .join(&feature_native) + .exists() ); } #[tokio::test] #[serial] -async fn armed_first_touch_recovery_defers_legacy_path_overlap_until_leaf_delete() { +async fn armed_first_touch_recovery_leaves_legacy_overlap_for_cleanup() { let _scenario = FailScenario::setup(); let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap().to_string(); @@ -1282,6 +1349,83 @@ async fn armed_first_touch_recovery_defers_legacy_path_overlap_until_leaf_delete .await .unwrap(); + let modern_entry = helpers::snapshot_branch(&db, "feature/child") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .clone(); + let modern_fork = modern_entry.native_dataset_branch.as_deref().unwrap(); + let table_uri = node_table_uri(&db, "Person").await; + let mut modern_table = lance::Dataset::open(&table_uri) + .await + .unwrap() + .checkout_branch(modern_fork) + .await + .unwrap(); + let legacy_table = modern_table + .create_branch( + "feature/child", + modern_entry.published_dataset_version, + None, + ) + .await + .unwrap(); + assert_eq!( + legacy_table.version().version, + modern_entry.published_dataset_version + ); + let child_native = helpers::graph_native_ref(&uri, "feature/child").await; + let legacy_location = legacy_table.manifest_location(); + let (_, legacy_suffix) = legacy_location + .path + .as_ref() + .rsplit_once(modern_entry.dataset_path.as_str()) + .unwrap(); + let legacy_metadata = serde_json::json!({ + "manifest_path": format!("{uri}/{}/{}", modern_entry.dataset_path, legacy_suffix.trim_start_matches('/')), + "manifest_size": legacy_location.size, + "e_tag": legacy_location.e_tag.clone(), + "naming_scheme": format!("{:?}", legacy_location.naming_scheme), + "table_fork_owner": child_native.clone(), + }); + let child_manifest = lance::Dataset::open(&format!("{uri}/__manifest")) + .await + .unwrap() + .checkout_branch(&child_native) + .await + .unwrap(); + let updated = lance::dataset::UpdateBuilder::new(std::sync::Arc::new(child_manifest)) + .update_where(&format!( + "table_key = 'node:Person' AND table_branch = '{modern_fork}'" + )) + .unwrap() + .set("table_branch", "'feature/child'") + .unwrap() + .set( + "metadata", + &format!("'{}'", legacy_metadata.to_string().replace('\'', "''")), + ) + .unwrap() + .build() + .unwrap() + .execute() + .await + .unwrap(); + assert_eq!(updated.rows_updated, 1); + + let child_entry = helpers::snapshot_branch(&db, "feature/child") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .clone(); + let child_fork = child_entry.native_dataset_branch.as_deref().unwrap(); + assert_eq!( + child_fork, "feature/child", + "the live table pin must physically descend from the orphan tree" + ); + // 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")) @@ -1308,10 +1452,9 @@ async fn armed_first_touch_recovery_defers_legacy_path_overlap_until_leaf_delete assert!(matches!(error, OmniError::RecoveryRequired { .. })); } let operation_id = single_sidecar_operation_id(dir.path()); + let planned_fork = saved_sidecar_table_fork(dir.path(), &operation_id, "node:Person"); + assert_ne!(planned_fork, "feature"); - // 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(&db, "Person").await; let mut person = lance::Dataset::open(&person_uri).await.unwrap(); let person_version = person.version().version; @@ -1331,8 +1474,7 @@ async fn armed_first_touch_recovery_defers_legacy_path_overlap_until_leaf_delete .list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature/child")), + .contains_key(child_fork), "precondition: live leaf table branch exists" ); assert!( @@ -1340,44 +1482,103 @@ async fn armed_first_touch_recovery_defers_legacy_path_overlap_until_leaf_delete .list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), + .contains_key("feature"), "precondition: ancestor is clone-only" ); + assert!( + !person + .list_branches() + .await + .unwrap() + .contains_key(&planned_fork) + ); + let legacy_tree = std::path::Path::new(&person_uri).join("tree/feature"); + assert!(legacy_tree.exists()); drop(person); drop(db); - let recovered = Omnigraph::open(&uri) + let mut recovered = Omnigraph::open(&uri) .await - .expect("legacy path overlap must defer cleanup instead of wedging read-write open"); + .expect("effect-free recovery must leave physical reclamation to cleanup"); + assert!(helpers::recovery::sidecar_operation_ids(dir.path()).is_empty()); + assert!(legacy_tree.exists()); + recovered + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); assert!( - dir.path() - .join("__recovery") - .join(format!("{operation_id}.json")) - .exists(), - "deferred cleanup must retain its ownership sidecar" + legacy_tree.exists(), + "a live descendant protects its physical ancestor tree" + ); + assert!( + lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap() + .contains_key(child_fork) + ); + let child_after = helpers::snapshot_branch(&recovered, "feature/child") + .await + .unwrap(); + let child_after = child_after.dataset("node:Person").unwrap(); + assert_eq!( + child_after.native_dataset_branch, + child_entry.native_dataset_branch + ); + assert_eq!( + child_after.published_dataset_version, + child_entry.published_dataset_version + ); + assert_eq!( + helpers::count_rows_branch(&recovered, "feature/child", "node:Person").await, + main_rows + 1 ); 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"); + + recovered + .branch_delete("feature/child") + .await + .expect("open handle must permit the documented leaf-first remediation"); + assert!( + legacy_tree.exists(), + "logical deletion must leave physical cleanup deferred" + ); + recovered + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + assert!( + !lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap() + .contains_key(child_fork) + ); drop(recovered); let recovered = Omnigraph::open(&uri) .await - .expect("the next Full sweep must finish ancestor cleanup"); + .expect("the store must reopen after explicit legacy subtree cleanup"); assert!( !dir.path() .join("__recovery") .join(format!("{operation_id}.json")) .exists(), - "recovery must retire the intent after the path child is gone" + "the retired intent must not reappear after cleanup" ); assert!( !std::path::Path::new(&person_uri) @@ -1394,7 +1595,7 @@ async fn armed_first_touch_recovery_defers_legacy_path_overlap_until_leaf_delete #[tokio::test] #[serial] -async fn partial_first_touch_recovery_fails_closed_on_legacy_path_overlap() { +async fn partial_first_touch_recovery_restores_in_place_with_path_descendants() { let _scenario = FailScenario::setup(); let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap().to_string(); @@ -1415,9 +1616,6 @@ async fn partial_first_touch_recovery_fails_closed_on_legacy_path_overlap() { }) .collect::>(); - // Forge a legacy (pre-incarnation) bare `feature` ref directly on the - // manifest: only a bare native name can physically overlap a - // `feature/child` path, which is the overlap this fixture exercises. { let mut manifest = lance::Dataset::open(&format!("{uri}/__manifest")) .await @@ -1449,127 +1647,112 @@ async fn partial_first_touch_recovery_fails_closed_on_legacy_path_overlap() { }; 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 forks = Vec::new(); let mut head_deltas = Vec::new(); - let mut heads_before_open = Vec::new(); for (table_key, table_uri, expected_version) in &table_pins { + let fork = saved_sidecar_table_fork(dir.path(), &operation_id, table_key); let mut root = lance::Dataset::open(table_uri).await.unwrap(); - let branches = root.list_branches().await.unwrap(); - assert!( - branches - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), - "precondition: {table_key} has its armed first-touch ref" - ); - let head = root - .checkout_branch(&helpers::native_ref_for(&root, "feature").await.unwrap()) - .await - .unwrap() - .version() - .version; - heads_before_open.push((table_key.clone(), head)); + assert!(root.list_branches().await.unwrap().contains_key(&fork)); + let dataset = root.checkout_branch(&fork).await.unwrap(); + let head = dataset.version().version; + let identifier = dataset.branch_identifier().await.unwrap(); 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) + let child = format!("{fork}/child"); + root.create_branch(&child, (fork.as_str(), head), None) .await .unwrap(); + let child_dataset = root.checkout_branch(&child).await.unwrap(); + let child_identifier = child_dataset.branch_identifier().await.unwrap(); + let child_rows = child_dataset.count_rows(None).await.unwrap(); + forks.push((fork, child, head, identifier, child_identifier, child_rows)); } 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" + "one table has an exact durable effect; its sibling is still at the fork point" ); + drop(db); - let mut manifest = lance::Dataset::open(&format!("{uri}/__manifest")) - .await - .unwrap(); - let feature_manifest_version = manifest - .checkout_branch(&helpers::native_ref_for(&manifest, "feature").await.unwrap()) + let mut recovered = Omnigraph::open(&uri) .await - .unwrap() - .version() - .version; - manifest - .create_branch("feature/child", ("feature", feature_manifest_version), None) + .expect("rollback must restore exact owned effects without deleting ancestor trees"); + assert!(helpers::recovery::sidecar_operation_ids(dir.path()).is_empty()); + assert_eq!(recovery_audit_kinds(dir.path()).await, vec!["RolledBack"]); + let restored = helpers::snapshot_branch(&recovered, "feature") .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 = - helpers::open_dataset_head(&format!("{uri}/__manifest"), Some("feature")) - .await - .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()) + for ( + (table_key, table_uri, expected_version), + (fork, child, old_head, identifier, child_identifier, child_rows), + ) in table_pins.iter().zip(&forks) { let root = lance::Dataset::open(table_uri).await.unwrap(); - let after = root - .checkout_branch(&helpers::native_ref_for(&root, "feature").await.unwrap()) - .await - .unwrap() - .version() - .version; + let branches = root.list_branches().await.unwrap(); + assert!(branches.contains_key(fork)); + assert!(branches.contains_key(child)); + let dataset = root.checkout_branch(fork).await.unwrap(); + assert_eq!(&dataset.branch_identifier().await.unwrap(), identifier); + let entry = restored.dataset(table_key).unwrap(); + if old_head > expected_version { + assert_eq!(dataset.version().version, old_head + 1); + assert_eq!(entry.native_dataset_branch.as_deref(), Some(fork.as_str())); + assert_eq!(entry.published_dataset_version, dataset.version().version); + } else { + assert_eq!(dataset.version().version, *old_head); + assert_eq!(entry.native_dataset_branch, None); + assert_eq!(entry.published_dataset_version, *expected_version); + } + let child_dataset = root.checkout_branch(child).await.unwrap(); assert_eq!( - after, *before, - "failed preflight must not restore or publish {table_key}" + &child_dataset.branch_identifier().await.unwrap(), + child_identifier ); + assert_eq!(child_dataset.version().version, *old_head); + assert_eq!(child_dataset.count_rows(None).await.unwrap(), *child_rows); } - - // 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) + 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 + ); + recovered + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + for ((table_key, table_uri, _), (fork, child, _, _, _, _)) in table_pins.iter().zip(&forks) { + let branches = lance::Dataset::open(table_uri) .await .unwrap() - .force_delete_branch("feature/child") + .list_branches() .await .unwrap(); + assert!(!branches.contains_key(child)); + assert_eq!( + branches.contains_key(fork), + restored + .dataset(table_key) + .unwrap() + .native_dataset_branch + .as_deref() + == Some(fork.as_str()), + "cleanup keeps the restored published fork and removes the untouched unused fork" + ); } - 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()); + drop(recovered); + let reopened = Omnigraph::open(&uri).await.unwrap(); assert_eq!( - helpers::count_rows_branch(&recovered, "feature", "node:Person").await, + helpers::count_rows_branch(&reopened, "feature", "node:Person").await, main_person_rows ); assert_eq!( - helpers::count_rows_branch(&recovered, "feature", "edge:Knows").await, + helpers::count_rows_branch(&reopened, "feature", "edge:Knows").await, main_edge_rows ); } @@ -1638,7 +1821,7 @@ async fn load_without_explicit_base_does_not_add_main_to_recovery_scope() { #[tokio::test] #[serial] -async fn armed_first_touch_recovery_reclaims_exact_no_effect_fork() { +async fn armed_first_touch_recovery_defers_exact_fork_cleanup() { let _scenario = FailScenario::setup(); for borrowed in [false, true] { Box::pin(async { @@ -1658,7 +1841,6 @@ async fn armed_first_touch_recovery_reclaims_exact_no_effect_fork() { .unwrap(); } db.branch_create("feature").await.unwrap(); - let feature_native = helpers::graph_native_ref(&uri, "feature").await; { let _failpoint = @@ -1675,6 +1857,7 @@ async fn armed_first_touch_recovery_reclaims_exact_no_effect_fork() { assert!(matches!(err, OmniError::RecoveryRequired { .. })); } let operation_id = single_sidecar_operation_id(dir.path()); + let feature_native = saved_sidecar_table_fork(dir.path(), &operation_id, "node:Person"); let person_uri = node_table_uri(&db, "Person").await; assert!( lance::Dataset::open(&person_uri) @@ -1683,8 +1866,7 @@ async fn armed_first_touch_recovery_reclaims_exact_no_effect_fork() { .list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), + .contains_key(&feature_native), "precondition: intent-owned target ref exists without a committed effect" ); if borrowed { @@ -1701,12 +1883,29 @@ async fn armed_first_touch_recovery_reclaims_exact_no_effect_fork() { } drop(db); - let recovered = Omnigraph::open(&uri).await.unwrap(); + let mut recovered = Omnigraph::open(&uri).await.unwrap(); assert_eq!( helpers::count_rows_branch(&recovered, "feature", "node:Person").await, main_rows, "recovery must leave the feature table inherited" ); + assert!( + lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap() + .contains_key(&feature_native), + "recovery retains the exact no-effect ref until cleanup" + ); + recovered + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); assert_eq!( lance::Dataset::open(&person_uri) .await @@ -1714,10 +1913,9 @@ async fn armed_first_touch_recovery_reclaims_exact_no_effect_fork() { .list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), + .contains_key(&feature_native), borrowed, - "recovery must reclaim only an unreferenced no-effect ref" + "cleanup must preserve the borrowed ref and collect only unreferenced residue", ); if borrowed { let child = recovered @@ -2052,11 +2250,8 @@ async fn full_recovery_converges_multiple_no_effect_claims_for_one_fork() { assert!(matches!(err, OmniError::RecoveryRequired { .. })); } let first_operation = single_sidecar_operation_id(dir.path()); + let feature_native = saved_sidecar_table_fork(dir.path(), &first_operation, "node:Person"); let recovery_dir = dir.path().join("__recovery"); - // A's sidecar predates A's fork, so its bytes are the pre-fork Armed claim - // a foreign B leaves when it dies before its fork; a newer ULID makes them - // B's. (A live B is rejected by the Stage-A re-check while A's sidecar is - // visible, and hiding that sidecar lets B's pre-arm proof reclaim A's fork.) let second_operation = ulid::Ulid::from_string(&first_operation) .unwrap() .increment() @@ -2089,13 +2284,12 @@ async fn full_recovery_converges_multiple_no_effect_claims_for_one_fork() { .list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), + .contains_key(&feature_native), "precondition: A's exact no-effect target ref exists" ); drop(db); - let recovered = Omnigraph::open(&uri) + let mut recovered = Omnigraph::open(&uri) .await .expect("ordered no-effect claim recovery must converge"); assert_eq!( @@ -2103,6 +2297,23 @@ async fn full_recovery_converges_multiple_no_effect_claims_for_one_fork() { main_rows ); assert!(helpers::recovery::sidecar_operation_ids(dir.path()).is_empty()); + assert!( + lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap() + .contains_key(&feature_native), + "retiring the last claim defers physical collection to cleanup" + ); + recovered + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); assert!( !lance::Dataset::open(&person_uri) .await @@ -2110,9 +2321,7 @@ async fn full_recovery_converges_multiple_no_effect_claims_for_one_fork() { .list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), - "the last no-effect claim must reclaim the exact unpublished ref" + .contains_key(&feature_native) ); } @@ -4296,13 +4505,21 @@ async fn recovery_rolls_forward_ensure_indices_on_feature_branch_inner() { // publisher deliberately skips the normal index-rebuild preparation; // the failed writer below is still the real `ensure_indices_on`. let person_uri = node_table_uri(&db, "Person").await; - let mut ds = helpers::open_dataset_head(&person_uri, Some("feature")).await; + let feature_fork = helpers::snapshot_branch(&db, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); + let mut ds = helpers::open_dataset_head_exact(&person_uri, Some(&feature_fork)).await; ds.drop_index("id_idx").await.unwrap(); let dropped_index_head = ds.version().version; db.failpoint_publish_table_head_without_index_rebuild_for_test( "feature", "node:Person", - Some("feature"), + Some(&feature_fork), ) .await .unwrap(); @@ -4384,12 +4601,12 @@ async fn recovery_rolls_forward_ensure_indices_on_feature_branch_inner() { // Repeat the same confirmed residual on the feature branch, but keep this // handle alive. The entry barrier must finish the roll-forward-eligible v8 // intent before the retry captures another base or plans another index. - let mut ds = helpers::open_dataset_head(&person_uri, Some("feature")).await; + let mut ds = helpers::open_dataset_head_exact(&person_uri, Some(&feature_fork)).await; ds.drop_index("id_idx").await.unwrap(); db.failpoint_publish_table_head_without_index_rebuild_for_test( "feature", "node:Person", - Some("feature"), + Some(&feature_fork), ) .await .unwrap(); @@ -4930,37 +5147,27 @@ async fn ensure_indices_first_touch_before_ref_case(full_text_rebuild: bool) { .await .unwrap(); - // Pre-arm ownership fence: an unregistered target ref is foreign/orphaned - // state, never something a new loose sidecar may claim. let person_uri = node_table_uri(&db, "Person").await; - let mut person = lance::Dataset::open(&person_uri).await.unwrap(); - let feature_native = helpers::native_ref_for(&person, "feature").await.unwrap(); + let source = helpers::snapshot_branch(&db, "feature").await.unwrap(); + let entry = source.dataset("node:Person").unwrap(); + let feature_native = entry.native_dataset_branch.clone().unwrap(); let experiment_native = helpers::graph_native_ref(&uri, "experiment").await; - let feature_head = person - .checkout_branch(&helpers::native_ref_for(&person, "feature").await.unwrap()) - .await - .unwrap() - .version() - .version; + let mut person = lance::Dataset::open(&person_uri).await.unwrap(); person .create_branch( &experiment_native, - (feature_native.as_str(), feature_head), + (feature_native.as_str(), entry.published_dataset_version), None, ) .await .unwrap(); - let orphan_error = run_index_maintenance(&db, "experiment", full_text_rebuild) + let orphan_identifier = person + .checkout_branch(&experiment_native) .await - .expect_err("pre-existing target ref must be refused before recovery is armed"); - assert!( - orphan_error - .to_string() - .contains("refusing to claim unowned physical state"), - "unexpected orphan-ref refusal: {orphan_error}" - ); - assert!(helpers::recovery::sidecar_operation_ids(dir.path()).is_empty()); - person.delete_branch(&experiment_native).await.unwrap(); + .unwrap() + .branch_identifier() + .await + .unwrap(); { let _failpoint = @@ -4970,9 +5177,19 @@ async fn ensure_indices_first_touch_before_ref_case(full_text_rebuild: bool) { .expect_err("failpoint must fire after sidecar and before target ref creation"); } let operation_id = single_sidecar_operation_id(dir.path()); + let attempted_fork = saved_sidecar_table_fork(dir.path(), &operation_id, "node:Person"); + assert_ne!(attempted_fork, experiment_native); + assert!( + !person + .list_branches() + .await + .unwrap() + .contains_key(&attempted_fork) + ); + drop(person); drop(db); - let recovered = Omnigraph::open(&uri) + let mut recovered = Omnigraph::open(&uri) .await .expect("Full recovery must accept the missing first-touch target ref"); assert!( @@ -4985,13 +5202,13 @@ async fn ensure_indices_first_touch_before_ref_case(full_text_rebuild: bool) { .snapshot_of(omnigraph::db::ReadTarget::branch("experiment")) .await .unwrap(); - helpers::assert_native_branch_of( + assert_eq!( inherited .dataset("node:Person") .unwrap() .native_dataset_branch .as_deref(), - "feature", + Some(feature_native.as_str()) ); run_index_maintenance(&recovered, "experiment", full_text_rebuild) @@ -5001,14 +5218,53 @@ async fn ensure_indices_first_touch_before_ref_case(full_text_rebuild: bool) { .snapshot_of(omnigraph::db::ReadTarget::branch("experiment")) .await .unwrap(); - helpers::assert_native_branch_of( - owned - .dataset("node:Person") + let owned_fork = owned + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); + assert_ne!(owned_fork, feature_native); + assert_ne!(owned_fork, experiment_native); + assert_ne!(owned_fork, attempted_fork); + let person = lance::Dataset::open(&person_uri).await.unwrap(); + assert_eq!( + person + .checkout_branch(&experiment_native) + .await .unwrap() - .native_dataset_branch - .as_deref(), - "experiment", + .branch_identifier() + .await + .unwrap(), + orphan_identifier + ); + assert!( + person + .list_branches() + .await + .unwrap() + .contains_key(&owned_fork) + ); + assert_eq!( + helpers::count_rows_branch(&recovered, "experiment", "node:Person").await, + 2 ); + recovered + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!(!branches.contains_key(&experiment_native)); + assert!(branches.contains_key(&owned_fork)); + assert!(branches.contains_key(&feature_native)); } /// Mixed first-touch recovery must clean only untouched refs. A table whose @@ -9434,6 +9690,14 @@ async fn branch_merge_post_effect_target_advance_requires_recovery_and_preserves } let merge_db = std::sync::Arc::new(Omnigraph::open(&uri).await.unwrap()); let mut target_winner = Omnigraph::open(&uri).await.unwrap(); + let company_native = helpers::snapshot_branch(&target_winner, "target") + .await + .unwrap() + .dataset("node:Company") + .unwrap() + .native_dataset_branch + .clone() + .expect("fixture must own the published table ref"); let source_head = branch_head_commit_id(dir.path(), "source").await.unwrap(); let merge_rv = helpers::failpoint::Rendezvous::park_first( @@ -9449,13 +9713,14 @@ async fn branch_merge_post_effect_target_advance_requires_recovery_and_preserves // still advances, invalidating the merge's coarse target authority token. // The test-only seam deliberately bypasses the process-local queues. let company_uri = node_table_uri(&target_winner, "Company").await; - let mut raw_company = helpers::open_dataset_head(&company_uri, Some("target")).await; + let mut raw_company = + helpers::open_dataset_head_exact(&company_uri, Some(&company_native)).await; helpers::lance_delete_inline(&mut raw_company, "1 = 2").await; target_winner .failpoint_publish_table_head_without_index_rebuild_for_test( "target", "node:Company", - Some("target"), + Some(&company_native), ) .await .unwrap(); @@ -9527,6 +9792,14 @@ async fn branch_merge_post_effect_same_table_advance_fails_closed() { let (uri, _) = setup_diverged_merge_branches(&dir).await; let merge_db = std::sync::Arc::new(Omnigraph::open(&uri).await.unwrap()); let mut target_winner = Omnigraph::open(&uri).await.unwrap(); + let target_native = helpers::snapshot_branch(&target_winner, "target") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .expect("fixture must own the published table ref"); let merge_rv = helpers::failpoint::Rendezvous::park_first( names::BRANCH_MERGE_POST_PHASE_B_PRE_MANIFEST_COMMIT, ); @@ -9537,14 +9810,14 @@ async fn branch_merge_post_effect_same_table_advance_fails_closed() { merge_rv.wait_until_reached().await; let person_uri = node_table_uri(&target_winner, "Person").await; - let mut raw_target = helpers::open_dataset_head(&person_uri, Some("target")).await; + let mut raw_target = helpers::open_dataset_head_exact(&person_uri, Some(&target_native)).await; helpers::lance_delete_inline(&mut raw_target, "1 = 2").await; let winner_lance_head = raw_target.version().version; target_winner .failpoint_publish_table_head_without_index_rebuild_for_test( "target", "node:Person", - Some("target"), + Some(&target_native), ) .await .unwrap(); @@ -9584,10 +9857,11 @@ async fn branch_merge_post_effect_same_table_advance_fails_closed() { winner_manifest_version, "failed recovery must not move the winning target manifest" ); - let lance_after_failed_recovery = helpers::open_dataset_head(&person_uri, Some("target")) - .await - .version() - .version; + let lance_after_failed_recovery = + helpers::open_dataset_head_exact(&person_uri, Some(&target_native)) + .await + .version() + .version; assert_eq!( lance_after_failed_recovery, winner_lance_head, "fail-closed recovery must not restore through the winning Lance HEAD" @@ -9621,6 +9895,22 @@ async fn branch_merge_rollback_restarts_after_restore_before_publish() { .await .unwrap(); let mut target_winner = Omnigraph::open(&uri).await.unwrap(); + let person_native = helpers::snapshot_branch(&db, "target") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .expect("fixture must own the published table ref"); + let company_native = helpers::snapshot_branch(&target_winner, "target") + .await + .unwrap() + .dataset("node:Company") + .unwrap() + .native_dataset_branch + .clone() + .expect("fixture must own the published table ref"); let operation_id = { let _failpoint = ScopedFailPoint::new( @@ -9639,20 +9929,21 @@ async fn branch_merge_rollback_restarts_after_restore_before_publish() { assert!(sidecar_path.exists()); let company_uri = node_table_uri(&target_winner, "Company").await; - let mut raw_company = helpers::open_dataset_head(&company_uri, Some("target")).await; + let mut raw_company = + helpers::open_dataset_head_exact(&company_uri, Some(&company_native)).await; helpers::lance_delete_inline(&mut raw_company, "1 = 2").await; target_winner .failpoint_publish_table_head_without_index_rebuild_for_test( "target", "node:Company", - Some("target"), + Some(&company_native), ) .await .unwrap(); let winner_head = branch_head_commit_id(dir.path(), "target").await.unwrap(); let person_uri = node_table_uri(&db, "Person").await; - let person_before_restore = helpers::open_dataset_head(&person_uri, Some("target")) + let person_before_restore = helpers::open_dataset_head_exact(&person_uri, Some(&person_native)) .await .version() .version; @@ -9686,10 +9977,11 @@ async fn branch_merge_rollback_restarts_after_restore_before_publish() { recovery_audit_kinds(dir.path()).await.is_empty(), "an interrupted rollback must not claim a completed audit outcome" ); - let person_after_interrupted_restore = helpers::open_dataset_head(&person_uri, Some("target")) - .await - .version() - .version; + let person_after_interrupted_restore = + helpers::open_dataset_head_exact(&person_uri, Some(&person_native)) + .await + .version() + .version; assert!( person_after_interrupted_restore > person_before_restore, "the fixture must durably restore Person before interrupting the manifest publish" @@ -9699,7 +9991,7 @@ async fn branch_merge_rollback_restarts_after_restore_before_publish() { .await .expect("the next open must recognize and finish the interrupted compensation"); assert!(!sidecar_path.exists()); - let person_after_recovery = helpers::open_dataset_head(&person_uri, Some("target")) + let person_after_recovery = helpers::open_dataset_head_exact(&person_uri, Some(&person_native)) .await .version() .version; @@ -9734,10 +10026,11 @@ async fn branch_merge_rollback_restarts_after_restore_before_publish() { drop(recovered); let _reopened = Omnigraph::open(&uri).await.unwrap(); - let person_after_second_open = helpers::open_dataset_head(&person_uri, Some("target")) - .await - .version() - .version; + let person_after_second_open = + helpers::open_dataset_head_exact(&person_uri, Some(&person_native)) + .await + .version() + .version; assert_eq!(person_after_second_open, person_after_recovery); assert_eq!( recovery_audit_kinds(dir.path()) @@ -9750,12 +10043,9 @@ async fn branch_merge_rollback_restarts_after_restore_before_publish() { ); } -/// A merge armed over an orphan target ref that crashes before reclaiming it -/// leaves the ref forked at a version the intent never named; recovery must -/// retire the intent and leave the graph openable and the merge retryable. #[tokio::test] #[serial(branch_merge_first_touch)] -async fn branch_merge_armed_over_orphan_ref_recovers_on_open() { +async fn branch_merge_pointer_ignores_fork_failpoint_and_keeps_orphan() { let _scenario = FailScenario::setup(); let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap().to_string(); @@ -9779,37 +10069,67 @@ async fn branch_merge_armed_over_orphan_ref_recovers_on_open() { .create_branch(&target_native, orphan_version, None) .await .unwrap(); + let orphan_identifier = person + .checkout_branch(&target_native) + .await + .unwrap() + .branch_identifier() + .await + .unwrap(); drop(person); - let operation_id = { + let source_before = helpers::snapshot_branch(&db, "source").await.unwrap(); + let source_entry = source_before.dataset("node:Person").unwrap(); + let target_before = helpers::snapshot_branch(&db, "target").await.unwrap(); + assert_ne!( + source_entry.native_dataset_branch, + target_before + .dataset("node:Person") + .unwrap() + .native_dataset_branch + ); + { let _failpoint = ScopedFailPoint::new(names::BRANCH_MERGE_POST_SIDECAR_PRE_FORK, "return"); - match db.branch_merge("source", "target").await.unwrap_err() { - OmniError::RecoveryRequired { operation_id, .. } => operation_id, - other => panic!("armed merge must retain recovery ownership: {other}"), - } - }; - let sidecar_path = dir - .path() - .join("__recovery") - .join(format!("{operation_id}.json")); - assert!(sidecar_path.exists()); + assert_eq!( + db.branch_merge("source", "target").await.unwrap(), + omnigraph::db::MergeOutcome::FastForward + ); + } + assert!(helpers::recovery::sidecar_operation_ids(dir.path()).is_empty()); drop(db); - let recovered = Omnigraph::open(&uri).await.unwrap(); - assert!(!sidecar_path.exists()); - recovered.branch_merge("source", "target").await.unwrap(); + let target_after = helpers::snapshot_branch(&recovered, "target") + .await + .unwrap(); + let target_entry = target_after.dataset("node:Person").unwrap(); + assert_eq!( + target_entry.native_dataset_branch, + source_entry.native_dataset_branch + ); + assert_eq!( + target_entry.published_dataset_version, + source_entry.published_dataset_version + ); + let person = lance::Dataset::open(&person_uri).await.unwrap(); + assert_eq!( + person + .checkout_branch(&target_native) + .await + .unwrap() + .branch_identifier() + .await + .unwrap(), + orphan_identifier + ); assert_eq!( helpers::count_rows_branch(&recovered, "target", "node:Person").await, helpers::count_rows_branch(&recovered, "source", "node:Person").await ); } -/// A pure first-touch/ref-only merge can reach EffectsConfirmed without any -/// data HEAD movement. Recovery must validate the minted ref identity and roll -/// the exact pointer delta forward, not discard it as an empty intent. #[tokio::test] #[serial(branch_merge_first_touch)] -async fn branch_merge_confirmed_ref_only_effect_rolls_forward() { +async fn branch_merge_pointer_failure_retries_without_sidecar() { let _scenario = FailScenario::setup(); let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap().to_string(); @@ -9830,8 +10150,6 @@ async fn branch_merge_confirmed_ref_only_effect_rolls_forward() { let target_native = helpers::graph_native_ref(&uri, "target").await; let mut person = lance::Dataset::open(&person_uri).await.unwrap(); let orphan_version = person.version().version; - let source_person = helpers::open_dataset_head(&person_uri, Some("source")).await; - assert!(source_person.version().version > orphan_version); // forbidden-api-allow: test synthesizes an unregistered target ref from an older main version. person .create_branch(&target_native, orphan_version, None) @@ -9840,53 +10158,83 @@ async fn branch_merge_confirmed_ref_only_effect_rolls_forward() { let orphan = person.checkout_branch(&target_native).await.unwrap(); let orphan_identifier = orphan.branch_identifier().await.unwrap(); drop(orphan); - drop(source_person); drop(person); assert!(helpers::recovery::sidecar_operation_ids(dir.path()).is_empty()); - let operation_id = { + let source_before = helpers::snapshot_branch(&db, "source").await.unwrap(); + let source_entry = source_before.dataset("node:Person").unwrap(); + let target_before = helpers::snapshot_branch(&db, "target").await.unwrap(); + { let _failpoint = ScopedFailPoint::new( names::BRANCH_MERGE_POST_PHASE_B_PRE_MANIFEST_COMMIT, "return", ); - match db.branch_merge("source", "target").await.unwrap_err() { - OmniError::RecoveryRequired { operation_id, .. } => operation_id, - other => panic!("confirmed ref-only merge must retain recovery ownership: {other}"), - } - }; - let sidecar_path = dir - .path() - .join("__recovery") - .join(format!("{operation_id}.json")); - let sidecar: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&sidecar_path).unwrap()).unwrap(); - assert_eq!(sidecar["protocol_v4"]["effect_phase"], "EffectsConfirmed"); - assert!(!sidecar["protocol_v4"]["effects"][0]["kind"]["confirmed_branch_identifier"].is_null()); + let error = db.branch_merge("source", "target").await.unwrap_err(); + assert!( + !matches!(error, OmniError::RecoveryRequired { .. }), + "a pointer-only failure has no table effect to recover" + ); + } + let still_target = helpers::snapshot_branch(&db, "target").await.unwrap(); + assert_eq!( + still_target + .dataset("node:Person") + .unwrap() + .native_dataset_branch, + target_before + .dataset("node:Person") + .unwrap() + .native_dataset_branch + ); + assert_eq!( + still_target + .dataset("node:Person") + .unwrap() + .published_dataset_version, + target_before + .dataset("node:Person") + .unwrap() + .published_dataset_version + ); + assert!(helpers::recovery::sidecar_operation_ids(dir.path()).is_empty()); + assert_eq!( + db.branch_merge("source", "target").await.unwrap(), + omnigraph::db::MergeOutcome::FastForward + ); + assert!(helpers::recovery::sidecar_operation_ids(dir.path()).is_empty()); drop(db); - let recovered = Omnigraph::open(&uri).await.unwrap(); - assert!(!sidecar_path.exists()); - let reforked = lance::Dataset::open(&person_uri) - .await - .unwrap() - .checkout_branch(&target_native) + let target_after = helpers::snapshot_branch(&recovered, "target") .await .unwrap(); - assert_ne!( - reforked.branch_identifier().await.unwrap(), - orphan_identifier, - "the merge must reclaim the orphan ref and fork afresh under its own intent" + let target_entry = target_after.dataset("node:Person").unwrap(); + assert_eq!( + target_entry.native_dataset_branch, + source_entry.native_dataset_branch + ); + assert_eq!( + target_entry.published_dataset_version, + source_entry.published_dataset_version + ); + let person = lance::Dataset::open(&person_uri).await.unwrap(); + assert_eq!( + person + .checkout_branch(&target_native) + .await + .unwrap() + .branch_identifier() + .await + .unwrap(), + orphan_identifier ); - drop(reforked); assert_eq!( helpers::count_rows_branch(&recovered, "target", "node:Person").await, - main_rows + 1 + helpers::count_rows_branch(&recovered, "source", "node:Person").await ); - let names = collect_column_strings( - &helpers::read_table_branch(&recovered, "target", "node:Person").await, - "name", + assert_eq!( + helpers::count_rows_branch(&recovered, "target", "node:Person").await, + main_rows + 1 ); - assert!(names.iter().any(|name| name == "confirmed-ref-row")); } /// Phase-B confirmation is an ownership proof, not a numeric HEAD stamp. A @@ -9930,6 +10278,14 @@ async fn branch_merge_confirmation_rejects_foreign_append_after_data_effects() { .expect("foreign seed row must be readable as one append batch"); let person_uri = node_table_uri(&db, "Person").await; + let target_native = helpers::snapshot_branch(&db, "target") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .expect("fixture must own the published table ref"); let target_table_version_before_merge = db .snapshot_of(omnigraph::db::ReadTarget::branch("target")) .await @@ -9951,7 +10307,7 @@ async fn branch_merge_confirmation_rejects_foreign_append_after_data_effects() { // The merge's logical data transaction is now at HEAD. Append a real // logical row without publishing target manifest authority, then let // confirmation classify that unowned tail. - let mut raw_target = helpers::open_dataset_head(&person_uri, Some("target")).await; + let mut raw_target = helpers::open_dataset_head_exact(&person_uri, Some(&target_native)).await; helpers::lance_append_inline(&mut raw_target, foreign_batch).await; let foreign_append_head = raw_target.version().version; merge_rv.release(); @@ -9970,10 +10326,11 @@ async fn branch_merge_confirmation_rejects_foreign_append_after_data_effects() { sidecar["protocol_v4"]["effect_phase"], "Armed", "confirmation must reject before persisting EffectsConfirmed" ); - let raw_head_after_foreign_append = helpers::open_dataset_head(&person_uri, Some("target")) - .await - .version() - .version; + let raw_head_after_foreign_append = + helpers::open_dataset_head_exact(&person_uri, Some(&target_native)) + .await + .version() + .version; assert_eq!(raw_head_after_foreign_append, foreign_append_head); assert_eq!( branch_head_commit_id(dir.path(), "target").await.unwrap(), @@ -10009,7 +10366,8 @@ async fn branch_merge_confirmation_rejects_foreign_append_after_data_effects() { target_table_version_before_merge, "target manifest must remain at its pre-merge Person pin" ); - let raw_after_failed_recovery = helpers::open_dataset_head(&person_uri, Some("target")).await; + let raw_after_failed_recovery = + helpers::open_dataset_head_exact(&person_uri, Some(&target_native)).await; assert_eq!( raw_after_failed_recovery.version().version, raw_head_after_foreign_append, @@ -10911,6 +11269,12 @@ async fn branch_merge_phase_b_failure_recovered_on_non_main_target_inner() { "fixture must exercise {case}" ); } + let target_person = helpers::snapshot_branch(&db, "target_branch") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .clone(); let source_head = branch_head_commit_id(dir.path(), source_branch) .await .unwrap(); @@ -10919,7 +11283,7 @@ async fn branch_merge_phase_b_failure_recovered_on_non_main_target_inner() { .unwrap(); drop(db); - let pointer_switch = !lazy_target && target_updates > 0; + let pointer_switch = target_updates > 0; let operation_id = { let db = Omnigraph::open(&uri).await.unwrap(); let _failpoint = ScopedFailPoint::new( @@ -10965,25 +11329,15 @@ async fn branch_merge_phase_b_failure_recovered_on_non_main_target_inner() { .unwrap(), None => { let db = Omnigraph::open(&uri).await.unwrap(); - let result = db - .query( - ReadTarget::branch("target_branch"), - TEST_QUERIES, - "get_person", - ¶ms(&[("$name", "alice")]), - ) + let unchanged = helpers::snapshot_branch(&db, "target_branch") .await .unwrap(); - let batch = result.concat_batches().unwrap(); - assert_eq!( - batch - .column(1) - .as_any() - .downcast_ref::() + assert!( + unchanged + .dataset("node:Person") .unwrap() - .value(0), - i32::try_from(39 + target_updates).unwrap(), - "{case}: a failed pointer switch leaves the target untouched" + .same_registration(&target_person), + "{case}: a failed pointer switch leaves the exact target registration untouched" ); assert_eq!( db.branch_merge(source_branch, "target_branch") @@ -11001,6 +11355,29 @@ async fn branch_merge_phase_b_failure_recovered_on_non_main_target_inner() { .await .unwrap(); let recovered_source_person = recovered_source.dataset("node:Person").unwrap(); + if pointer_switch { + let adopted = helpers::snapshot_branch(&db, "target_branch") + .await + .unwrap(); + let adopted_person = adopted.dataset("node:Person").unwrap(); + assert_eq!( + ( + &adopted_person.type_key, + &adopted_person.dataset_path, + adopted_person.published_dataset_version, + &adopted_person.native_dataset_branch, + adopted_person.entity_count, + ), + ( + &source_person.type_key, + &source_person.dataset_path, + source_person.published_dataset_version, + &source_person.native_dataset_branch, + source_person.entity_count, + ), + "{case}: retry must adopt the exact source table pointer and row count" + ); + } assert_eq!( recovered_source_person.published_dataset_version, source_person.published_dataset_version, @@ -11069,25 +11446,8 @@ async fn branch_merge_phase_b_failure_recovered_on_non_main_target_inner() { } } -/// Contract: the BranchMerge sidecar's per-table `table_branch` MUST be -/// the merge target branch (where commits land via -/// `publish_rewritten_merge_table` → `open_for_mutation` → potentially -/// `fork_dataset_from_entry_state`), NOT `entry.native_dataset_branch` (where -/// the table currently lives in the target's manifest snapshot). -/// -/// `ensure_indices_for_branch` already has this invariant pinned by an -/// explicit comment at `table_ops.rs:115-120`. Without the same fix in -/// `merge.rs`, a future change to candidate selection or the publish -/// path that produces a `RewriteMerged` whose entry.native_dataset_branch -/// diverges from active_branch would silently drift Lance HEAD on the -/// target ref while recovery checks the wrong ref and no-ops the -/// rollback. -/// -/// This test reads the sidecar JSON directly and asserts every per-pin -/// `table_branch` equals the active (target) branch. Even when the -/// values happen to coincide in practice (the strict candidate logic -/// keeps RewriteMerged tables on active_branch), the contract assertion -/// catches a regression that reverts to `entry.native_dataset_branch.clone()`. +/// BranchMerge pins the exact physical writer ref and its graph owner. +/// Existing-ref recovery must use the accepted table ref captured before effects. #[tokio::test] #[serial] #[serial(branch_merge_phase_b)] @@ -11128,6 +11488,12 @@ async fn branch_merge_sidecar_pins_table_branch_to_active_branch() { .unwrap(); } + let db = Omnigraph::open(&uri).await.unwrap(); + let target_snapshot = helpers::snapshot_branch(&db, "target_branch") + .await + .unwrap(); + let target_owner = helpers::graph_native_ref(&uri, "target_branch").await; + drop(db); { let db = Omnigraph::open(&uri).await.unwrap(); let _failpoint = ScopedFailPoint::new( @@ -11165,12 +11531,20 @@ async fn branch_merge_sidecar_pins_table_branch_to_active_branch() { got pin {pin:?}" ) }); - assert!( - helpers::is_incarnation_of(table_branch, "target_branch"), - "sidecar pin must record `table_branch` as the merge target branch (where \ - commits actually land via publish_rewritten_merge_table → open_for_mutation), \ - NOT entry.native_dataset_branch from the target snapshot. See merge.rs filter_map and \ - the rationale comment at table_ops.rs:115-120. Got pin: {pin:?}" + let key = pin["table_key"].as_str().unwrap(); + assert_eq!( + Some(table_branch), + target_snapshot + .dataset(key) + .unwrap() + .native_dataset_branch + .as_deref(), + "existing-ref merge recovery must pin the exact published writer ref" + ); + assert_eq!( + pin["table_fork_owner"].as_str(), + Some(target_owner.as_str()), + "recovery must retain the graph owner alongside the physical writer ref" ); } } @@ -11817,7 +12191,7 @@ async fn publisher_retries_retryable_load_publish_state_error() { /// `create_branch` can succeed before reopening the new ref fails. That error is /// post-effect even on the first deferred table: the v3 sidecar must remain so -/// Full recovery can reclaim the exact untouched ref. +/// Full recovery can retire its intent before cleanup collects the untouched ref. #[tokio::test] #[serial] async fn first_touch_post_create_open_error_keeps_recovery_ownership() { @@ -11826,6 +12200,12 @@ async fn first_touch_post_create_open_error_keeps_recovery_ownership() { let uri = dir.path().to_str().unwrap().to_string(); let db = helpers::init_and_load(&dir).await; db.branch_create("feature").await.unwrap(); + let before = helpers::snapshot_branch(&db, "feature").await.unwrap(); + let base_manifest_version = before.graph_manifest_version(); + let base_graph_head = before.graph_head(Some("feature")).map(str::to_string); + let owner = helpers::graph_native_ref(&uri, "feature").await; + let (_, incarnation) = owner.rsplit_once('.').unwrap(); + let fork_prefix = format!("fork.{incarnation}.m{base_manifest_version}."); let error = { let _fp = ScopedFailPoint::new(names::FORK_POST_CREATE_PRE_OPEN, "return"); @@ -11846,6 +12226,9 @@ async fn first_touch_post_create_open_error_keeps_recovery_ownership() { 1, "ambiguous post-create failure must retain its ownership sidecar" ); + let operation_id = single_sidecar_operation_id(dir.path()); + let feature_native = saved_sidecar_table_fork(dir.path(), &operation_id, "node:Person"); + assert!(feature_native.starts_with(&fork_prefix)); let person_uri = node_table_uri(&db, "Person").await; assert!( lance::Dataset::open(&person_uri) @@ -11854,29 +12237,94 @@ async fn first_touch_post_create_open_error_keeps_recovery_ownership() { .list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), + .contains_key(&feature_native), "test seam fires only after the target ref is durable" ); drop(db); - let recovered = Omnigraph::open(&uri).await.unwrap(); + let mut recovered = Omnigraph::open(&uri).await.unwrap(); assert_eq!( helpers::count_rows_branch(&recovered, "feature", "node:Person").await, 4, "failed first touch must not publish its row" ); assert!( - !lance::Dataset::open(&person_uri) + lance::Dataset::open(&person_uri) .await .unwrap() .list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), - "Full recovery must reclaim the sidecar-owned untouched ref" + .contains_key(&feature_native), + "Full recovery retains the exact untouched ref for cleanup" + ); + assert!(helpers::recovery::sidecar_operation_ids(dir.path()).is_empty()); + let retry_base = helpers::snapshot_branch(&recovered, "feature") + .await + .unwrap(); + assert_eq!( + retry_base.graph_manifest_version(), + base_manifest_version, + "an effect-free recovery leaves the next attempt on the same manifest base" + ); + assert_eq!( + retry_base.graph_head(Some("feature")), + base_graph_head.as_deref(), + "an effect-free recovery must not manufacture a graph commit" + ); + recovered + .mutate( + "feature", + MUTATION_QUERIES, + "insert_person", + &mixed_params(&[("$name", "retry-after-recovery")], &[("$age", 23)]), + ) + .await + .unwrap(); + let published = helpers::snapshot_branch(&recovered, "feature") + .await + .unwrap(); + let fresh_fork = published + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); + assert!(fresh_fork.starts_with(&fork_prefix)); + assert_ne!( + fresh_fork, feature_native, + "a new attempt on the same manifest base must never reuse the abandoned fork" ); + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!(branches.contains_key(&feature_native)); + assert!(branches.contains_key(&fresh_fork)); + recovered + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + let branches = lance::Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!(!branches.contains_key(&feature_native)); + assert!(branches.contains_key(&fresh_fork)); + drop(recovered); + let reopened = Omnigraph::open(&uri).await.unwrap(); + let rows = helpers::read_table_branch(&reopened, "feature", "node:Person").await; + let names = collect_column_strings(&rows, "name"); + assert_eq!(names.len(), 5); + assert!(names.iter().any(|name| name == "retry-after-recovery")); + assert!(!names.iter().any(|name| name == "post-create")); } /// A branch delete's first recovery probe is not its authority boundary. A data @@ -12557,7 +13005,16 @@ async fn branch_merge_fences_target_delete_recreate_aba() { // version; BranchIdentifier is the incarnation component that prevents // that pair from masquerading as the authority captured by the merge. let person_uri = node_table_uri(merge_db.as_ref(), "Person").await; - let old_target = helpers::open_dataset_head(&person_uri, Some("target")).await; + let old_snapshot = helpers::snapshot_branch(merge_db.as_ref(), "target") + .await + .unwrap(); + let old_target_ref = old_snapshot + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); + let old_target = helpers::open_dataset_head_exact(&person_uri, Some(&old_target_ref)).await; let old_target_version = old_target.version().version; let old_target_identifier = old_target.branch_identifier().await.unwrap(); @@ -12594,12 +13051,13 @@ async fn branch_merge_fences_target_delete_recreate_aba() { tokio::time::timeout(std::time::Duration::from_millis(250), &mut control_task) .await .is_err(); - let target_unchanged_while_parked = helpers::open_dataset_head(&person_uri, Some("target")) - .await - .branch_identifier() - .await - .unwrap() - == old_target_identifier; + let target_unchanged_while_parked = + helpers::open_dataset_head_exact(&person_uri, Some(&old_target_ref)) + .await + .branch_identifier() + .await + .unwrap() + == old_target_identifier; // Always release before assertions so a failed oracle cannot strand the // parked callback thread for its 30-second safety bound. merge_rv.release(); @@ -12637,7 +13095,7 @@ async fn branch_merge_fences_target_delete_recreate_aba() { && !target_names.iter().any(|name| name == "old-target-only"), "recreated target leaked state from the deleted target incarnation: {target_names:?}" ); - let new_target = helpers::open_dataset_head(&person_uri, Some("target")).await; + let new_target = helpers::open_published_dataset_head(&reopened, "target", "node:Person").await; assert_ne!( new_target.branch_identifier().await.unwrap(), old_target_identifier, @@ -12706,6 +13164,14 @@ async fn branch_merge_rejects_fresh_target_manifest_change_before_effects() { let (uri, _) = setup_diverged_merge_branches(&dir).await; let merge_db = std::sync::Arc::new(Omnigraph::open(&uri).await.unwrap()); let mut target_writer = Omnigraph::open(&uri).await.unwrap(); + let target_native = helpers::snapshot_branch(&target_writer, "target") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .expect("fixture must own the published table ref"); let merge_rv = helpers::failpoint::Rendezvous::park_first(names::BRANCH_MERGE_POST_AUTHORITY_CAPTURE); @@ -12720,13 +13186,13 @@ async fn branch_merge_rejects_fresh_target_manifest_change_before_effects() { // `graph_head`; the merge handle's cached target snapshot remains at // `before`. let person_uri = node_table_uri(&target_writer, "Person").await; - let mut raw_target = helpers::open_dataset_head(&person_uri, Some("target")).await; + let mut raw_target = helpers::open_dataset_head_exact(&person_uri, Some(&target_native)).await; helpers::lance_delete_inline(&mut raw_target, "1 = 2").await; let publish_result = target_writer .failpoint_publish_table_head_without_index_rebuild_for_test( "target", "node:Person", - Some("target"), + Some(&target_native), ) .await; let after_result = helpers::version_branch(&target_writer, "target").await; @@ -12856,6 +13322,14 @@ async fn branch_merge_source_advance_keeps_captured_source_parent() { let (uri, main_rows) = setup_diverged_merge_branches(&dir).await; let merge_db = std::sync::Arc::new(Omnigraph::open(&uri).await.unwrap()); let mut source_writer = Omnigraph::open(&uri).await.unwrap(); + let source_native = helpers::snapshot_branch(&source_writer, "source") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .expect("fixture must own the published table ref"); let captured_source_head = branch_head_commit_id(dir.path(), "source").await.unwrap(); let merge_rv = helpers::failpoint::Rendezvous::park_first(names::BRANCH_MERGE_POST_AUTHORITY_CAPTURE); @@ -12869,13 +13343,13 @@ async fn branch_merge_source_advance_keeps_captured_source_parent() { // source table HEAD with a no-op delete, then publish it through the // queue-bypassing seam. The source branch incarnation remains unchanged. let person_uri = node_table_uri(&source_writer, "Person").await; - let mut raw_source = helpers::open_dataset_head(&person_uri, Some("source")).await; + let mut raw_source = helpers::open_dataset_head_exact(&person_uri, Some(&source_native)).await; helpers::lance_delete_inline(&mut raw_source, "1 = 2").await; source_writer .failpoint_publish_table_head_without_index_rebuild_for_test( "source", "node:Person", - Some("source"), + Some(&source_native), ) .await .unwrap(); @@ -12930,7 +13404,15 @@ async fn branch_merge_pure_insert_rejects_source_table_ref_aba_before_arm() { .unwrap(); let person_uri = node_table_uri(&db, "Person").await; - let old_source = helpers::open_dataset_head(&person_uri, Some("source")).await; + let source_native = helpers::snapshot_branch(&db, "source") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .expect("fixture must own the published table ref"); + let old_source = helpers::open_dataset_head_exact(&person_uri, Some(&source_native)).await; let old_source_version = old_source.version().version; let old_source_identifier = old_source.branch_identifier().await.unwrap(); let merge_db = std::sync::Arc::new(db); @@ -12950,7 +13432,6 @@ async fn branch_merge_pure_insert_rejects_source_table_ref_aba_before_arm() { .await .map_err(OmniError::storage)?; let main_version = root.version().version; - let source_native = helpers::native_ref_for(&root, "source").await.unwrap(); root.force_delete_branch(&source_native) .await .map_err(OmniError::storage)?; @@ -12979,7 +13460,7 @@ async fn branch_merge_pure_insert_rejects_source_table_ref_aba_before_arm() { .failpoint_publish_table_head_without_index_rebuild_for_test( "source", "node:Person", - Some("source"), + Some(&source_native), ) .await?; Ok::<_, OmniError>(( @@ -13032,15 +13513,11 @@ async fn branch_merge_pure_insert_rejects_source_table_ref_aba_before_arm() { ); } -/// The no-target-probe pure-insert route is sound only while the live target -/// table ref is the exact native incarnation on which the source absence proof -/// was founded. Replace an already-owned target ref outside OmniGraph's queues -/// at the same numeric version and with the same logical rows; the final -/// under-gate BranchIdentifier check must reject that ABA before recovery is -/// armed or the graph-visible target moves. +/// Named pointer adoption does not write the former target table ref. +/// Replacing that unused ref must leave source proof and graph authority intact. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[serial] -async fn branch_merge_pure_insert_rejects_target_table_ref_aba_before_arm() { +async fn branch_merge_pointer_adoption_preserves_replaced_former_target_ref() { let _scenario = FailScenario::setup(); let dir = tempfile::tempdir().unwrap(); let db = helpers::init_and_load(&dir).await; @@ -13080,15 +13557,22 @@ async fn branch_merge_pure_insert_rejects_target_table_ref_aba_before_arm() { assert_eq!(target_entry.entity_count, main_rows as u64); let expected_target_version = target_entry.published_dataset_version; let target_head_before = branch_head_commit_id(dir.path(), "target").await.unwrap(); + let source_before = helpers::snapshot_branch(&db, "source").await.unwrap(); + let source_entry = source_before.dataset("node:Person").unwrap().clone(); + let target_native = target_entry.native_dataset_branch.clone().unwrap(); let source_head_before = branch_head_commit_id(dir.path(), "source").await.unwrap(); let person_uri = node_table_uri(&db, "Person").await; - let old_target = helpers::open_dataset_head(&person_uri, Some("target")).await; + let old_target = helpers::open_dataset_head_exact(&person_uri, Some(&target_native)).await; assert_eq!(old_target.version().version, expected_target_version); let old_target_identifier = old_target.branch_identifier().await.unwrap(); - let target_native = helpers::native_ref_for(&old_target, "target") - .await - .unwrap(); + let source_table = helpers::open_dataset_head_exact( + &person_uri, + source_entry.native_dataset_branch.as_deref(), + ) + .await; + let source_native_head = source_table.version().version; + let source_native_identifier = source_table.branch_identifier().await.unwrap(); let merge_db = std::sync::Arc::new(db); let merge_rv = @@ -13101,11 +13585,6 @@ async fn branch_merge_pure_insert_rejects_target_table_ref_aba_before_arm() { }); merge_rv.wait_until_reached().await; - // Lance's public branch API correctly refuses to delete a target ref that - // the source identifier still references. Simulate the adversarial - // lower-level ABA by replacing only the authoritative BranchContents - // identifier. The target tree, path, numeric version, and logical rows all - // remain unchanged, so only incarnation-aware validation can catch it. let target_ref_path = std::path::Path::new(&person_uri) .join("_refs") .join("branches") @@ -13137,7 +13616,8 @@ async fn branch_merge_pure_insert_rejects_target_table_ref_aba_before_arm() { replacement_identifier, old_target_identifier, "raw ref replacement must mint a distinct native target-table incarnation" ); - let replacement_target = helpers::open_dataset_head(&person_uri, Some("target")).await; + let replacement_target = + helpers::open_dataset_head_exact(&person_uri, Some(&target_native)).await; assert_eq!( replacement_target.version().version, expected_target_version, @@ -13148,65 +13628,77 @@ async fn branch_merge_pure_insert_rejects_target_table_ref_aba_before_arm() { replacement_identifier, "fixture must expose the replacement native target-table incarnation" ); - assert!( - probes.proven_insert_raw_batch_calls() > 0, - "fixture must reach the proven source-interval route before parking" + assert_eq!( + merge_task.await.unwrap().unwrap(), + omnigraph::db::MergeOutcome::FastForward ); + assert_eq!(probes.stage_fenced_insert_calls(), 0); + assert_eq!(probes.stage_merge_insert_calls(), 0); + assert_eq!(probes.stage_append_calls(), 0); + let adopted = helpers::snapshot_branch(&merge_db, "target").await.unwrap(); + let adopted_entry = adopted.dataset("node:Person").unwrap(); assert_eq!( - probes.ordered_cursor_scan_calls(), - 0, - "fixture must not fall back to the general ordered diff" + ( + &adopted_entry.type_key, + &adopted_entry.dataset_path, + adopted_entry.published_dataset_version, + &adopted_entry.native_dataset_branch, + adopted_entry.entity_count, + ), + ( + &source_entry.type_key, + &source_entry.dataset_path, + source_entry.published_dataset_version, + &source_entry.native_dataset_branch, + source_entry.entity_count, + ), + "pointer adoption must preserve the exact source table pointer despite former target ref replacement" ); - - let error = merge_task - .await - .unwrap() - .expect_err("merge must reject the replacement target-table ref"); - let OmniError::Manifest(manifest_error) = error else { - panic!("expected a typed read-set conflict"); - }; - assert!(matches!( - manifest_error.details, - Some(omnigraph::error::ManifestConflictDetails::ReadSetChanged { - ref member, - .. - }) if member == "branch_merge_target_dataset_incarnation:node:Person" - )); - + let source_after = helpers::open_dataset_head_exact( + &person_uri, + source_entry.native_dataset_branch.as_deref(), + ) + .await; + assert_eq!(source_after.version().version, source_native_head); assert_eq!( + source_after.branch_identifier().await.unwrap(), + source_native_identifier + ); + + assert_ne!( branch_head_commit_id(dir.path(), "target").await.unwrap(), target_head_before, - "pre-arm target ABA must not publish merge lineage" + "pointer adoption publishes its source registration and graph lineage" ); assert_eq!( branch_head_commit_id(dir.path(), "source").await.unwrap(), source_head_before, - "rejected merge must not move its captured source" + "pointer adoption must not move its captured source" ); assert_eq!( helpers::count_rows_branch(&merge_db, "target", "node:Person").await, - main_rows, - "rejected merge must leave the target graph row image unchanged" + main_rows + 1, + "pointer adoption must publish the exact source row image" ); assert_eq!( helpers::count_rows_branch(&merge_db, "source", "node:Person").await, main_rows + 1, - "rejected merge must leave the source graph row image unchanged" + "pointer adoption must leave the source graph row image unchanged" ); let target_names = helpers::collect_column_strings( &helpers::read_table_branch(&merge_db, "target", "node:Person").await, "name", ); assert!( - !target_names.iter().any(|name| name == "source-only"), - "source-only row leaked into rejected target merge: {target_names:?}" + target_names.iter().any(|name| name == "source-only"), + "adopted source row is missing: {target_names:?}" ); - let final_target = helpers::open_dataset_head(&person_uri, Some("target")).await; + let final_target = helpers::open_dataset_head_exact(&person_uri, Some(&target_native)).await; assert_eq!(final_target.version().version, expected_target_version); assert_eq!( final_target.branch_identifier().await.unwrap(), replacement_identifier, - "rejected merge must not move the raw replacement target ref" + "pointer adoption must not write the abandoned replacement target ref" ); assert!( !dir.path().join("__recovery").exists() @@ -13214,7 +13706,7 @@ async fn branch_merge_pure_insert_rejects_target_table_ref_aba_before_arm() { .unwrap() .next() .is_none(), - "target ABA must fail before recovery is armed" + "pointer adoption must not arm physical-effect recovery" ); } @@ -13227,7 +13719,20 @@ async fn assert_branch_merge_first_touch_ref_is_recovered( let db = helpers::init_and_load(&dir).await; let main_rows = helpers::count_rows(&db, "node:Person").await; db.branch_create("source").await.unwrap(); - db.branch_create("target").await.unwrap(); + db.branch_create("donor").await.unwrap(); + db.mutate( + "donor", + MUTATION_QUERIES, + "insert_person", + &mixed_params(&[("$name", "target-inherited")], &[("$age", 38)]), + ) + .await + .unwrap(); + db.branch_create_from(ReadTarget::branch("donor"), "target") + .await + .unwrap(); + let inherited = helpers::snapshot_branch(&db, "target").await.unwrap(); + let inherited_entry = inherited.dataset("node:Person").unwrap(); db.mutate( "source", MUTATION_QUERIES, @@ -13238,16 +13743,7 @@ async fn assert_branch_merge_first_touch_ref_is_recovered( .unwrap(); let person_uri = node_table_uri(&db, "Person").await; - let person = lance::Dataset::open(&person_uri).await.unwrap(); - assert!( - !person - .list_branches() - .await - .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "target")), - "fixture requires the target table ref to be lazy" - ); + assert!(inherited_entry.native_dataset_branch.is_some()); let error = { let _failpoint = ScopedFailPoint::new(failpoint, "return"); @@ -13266,47 +13762,73 @@ async fn assert_branch_merge_first_touch_ref_is_recovered( assert_eq!(sidecar["schema_version"], 9); assert_eq!( sidecar["protocol_v4"]["effects"][0]["kind"]["kind"], - "RefOnlyFork" + "MultiCommitHead" ); + let target_fork = saved_sidecar_table_fork(dir.path(), &operation_id, "node:Person"); let person = lance::Dataset::open(&person_uri).await.unwrap(); assert_eq!( person .list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "target")), + .contains_key(&target_fork), ref_exists_before_recovery, "fixture must stop at the intended sidecar/ref boundary" ); drop(db); - let recovered = Omnigraph::open(&uri).await.unwrap(); + let mut recovered = Omnigraph::open(&uri).await.unwrap(); assert!(!sidecar_path.exists()); let person = lance::Dataset::open(&person_uri).await.unwrap(); + assert_eq!( + person + .list_branches() + .await + .unwrap() + .contains_key(&target_fork), + ref_exists_before_recovery, + "recovery retains any created first-touch ref until cleanup" + ); + let restored = helpers::snapshot_branch(&recovered, "target") + .await + .unwrap(); + assert_eq!( + restored + .dataset("node:Person") + .unwrap() + .native_dataset_branch, + inherited_entry.native_dataset_branch + ); + recovered + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); assert!( - !person + !lance::Dataset::open(&person_uri) + .await + .unwrap() .list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "target")), - "Full recovery must reclaim an unpublished first-touch target ref" + .contains_key(&target_fork) ); assert_eq!( helpers::count_rows_branch(&recovered, "target", "node:Person").await, - main_rows, + main_rows + 1, "failed first-touch merge must leave target inheriting its old image" ); assert_eq!( recovered.branch_merge("source", "target").await.unwrap(), - omnigraph::db::MergeOutcome::FastForward + omnigraph::db::MergeOutcome::Merged ); assert_eq!( helpers::count_rows_branch(&recovered, "target", "node:Person").await, - main_rows + 1 + main_rows + 2 ); } diff --git a/crates/omnigraph/tests/forbidden_apis.rs b/crates/omnigraph/tests/forbidden_apis.rs index f496570f..7259c377 100644 --- a/crates/omnigraph/tests/forbidden_apis.rs +++ b/crates/omnigraph/tests/forbidden_apis.rs @@ -145,6 +145,7 @@ const ALLOW_LIST_FILES: &[&str] = &[ "instrumentation.rs", // The instrumented dataset opener. "db/manifest/upgrade.rs", "db/manifest/upgrade/tests.rs", + "storage_layer/lance_clone.rs", ]; /// Out-of-line test modules are parsed as standalone files, so their enclosing @@ -279,9 +280,6 @@ const READ_ONLY_SURFACES: &[(&str, &str)] = &[ ("db/omnigraph.rs", "graph_index"), ("blob.rs", "read_blob_at"), ("db/omnigraph.rs", "branch_list"), - // Joins already-dispatched branch_delete reclaims; performs no durable - // calls itself (the reclaim tasks' call sites are inventoried per-file). - ("db/omnigraph.rs", "wait_for_fork_reclaims"), ("db/omnigraph.rs", "get_commit"), ("db/omnigraph.rs", "list_commits"), ("exec/query.rs", "query"), @@ -348,11 +346,6 @@ const LOW_LEVEL_READ_ONLY_SURFACES: &[(&str, &str, &str)] = &[ "GraphCoordinator", "all_branches", ), - ( - "db/graph_coordinator.rs", - "GraphCoordinator", - "native_branches_and_descendants", - ), ( "db/graph_coordinator.rs", "GraphCoordinator", @@ -424,6 +417,11 @@ const LOW_LEVEL_READ_ONLY_SURFACES: &[(&str, &str, &str)] = &[ "ManifestCoordinator", "native_fork_references_under_control_gates", ), + ( + "db/manifest.rs", + "ManifestCoordinator", + "table_registrations_under_control_gates", + ), ( "db/manifest.rs", "ManifestCoordinator", @@ -450,16 +448,6 @@ const LOW_LEVEL_READ_ONLY_SURFACES: &[(&str, &str, &str)] = &[ "ManifestCoordinator", "list_graph_branches", ), - ( - "db/manifest.rs", - "ManifestCoordinator", - "native_branches_and_descendants", - ), - ( - "db/manifest.rs", - "ManifestCoordinator", - "branch_depends_on_delete_target_under_control_gates", - ), ]; const LOW_LEVEL_WRITE_SURFACES: &[(&str, &str, &str, WriteProtocol)] = &[ @@ -826,13 +814,13 @@ durable_calls! { ("db/graph_coordinator.rs", ".create_branch(", 1, WriteProtocol::NativeRefControl), ("db/graph_coordinator.rs", ".delete_branch(", 1, WriteProtocol::NativeRefControl), ("db/graph_coordinator.rs", ".delete_branch_with_expected(", 1, WriteProtocol::NativeRefControl), - ("branch_control.rs", ".create_branch(", 1, WriteProtocol::Composed("graph/data native refs")), - ("branch_control.rs", ".delete_branch(", 1, WriteProtocol::Composed("graph/data native refs")), + ("branch_control.rs", ".create_branch(", 2, WriteProtocol::Composed("graph/data native refs")), + ("storage_layer/lance_clone.rs", ".create_branch(", 1, WriteProtocol::Composed("scoped native clone index-origin forwarding")), + ("storage_layer/lance_clone.rs", ".commit(", 1, WriteProtocol::Composed("Lance commit-handler publication forwarding")), + ("storage_layer/lance_clone.rs", ".delete(", 1, WriteProtocol::Composed("Lance commit-handler deletion forwarding")), + ("branch_control.rs", ".replace_metadata(", 1, WriteProtocol::NativeRefControl), ("branch_control.rs", ".force_delete_branch(", 1, WriteProtocol::Composed("graph/data native refs")), - ("db/omnigraph.rs", ".force_delete_branch(", 1, WriteProtocol::NativeRefControl), - ("db/omnigraph/table_ops.rs", ".force_delete_branch(", 1, WriteProtocol::Composed("first-touch reclaim")), ("db/omnigraph/optimize.rs", ".force_delete_branch(", 1, WriteProtocol::PhysicalOnly), - ("db/manifest/recovery.rs", ".force_delete_branch(", 2, WriteProtocol::RecoveryExecutor), ("db/manifest/recovery.rs", ".publish_with_precondition(", 1, WriteProtocol::RecoveryExecutor), ("db/manifest/recovery.rs", ".publish(", 1, WriteProtocol::RecoveryExecutor), ("db/manifest/recovery.rs", ".restore(", 1, WriteProtocol::RecoveryExecutor), @@ -849,11 +837,9 @@ durable_calls! { ("exec/merge.rs", "TableStore::create_empty_dataset(", 1, WriteProtocol::EphemeralScratch), ("exec/merge.rs", "TableStore::append_or_create_batch(", 1, WriteProtocol::EphemeralScratch), // First-touch merge: enumerate native refs before arming recovery; no mutation. - ("exec/merge.rs", ".dataset()", 1, WriteProtocol::ReadOnlyAccess), // First-touch write: enumerate native refs before arming recovery; no mutation. - ("exec/staging.rs", ".dataset()", 1, WriteProtocol::ReadOnlyAccess), ("db/omnigraph.rs", ".dataset()", 1, WriteProtocol::ReadOnlyAccess), - ("db/omnigraph/table_ops.rs", ".dataset()", 2, WriteProtocol::ReadOnlyAccess), + ("db/omnigraph/table_ops.rs", ".dataset()", 1, WriteProtocol::ReadOnlyAccess), ("db/omnigraph/export.rs", ".dataset()", 2, WriteProtocol::ReadOnlyAccess), // Blob live-branch recheck: lists the table's refs to prove a vanished // fork before the incarnation refusal; read-only access to the handle. @@ -874,7 +860,7 @@ durable_calls! { ("db/omnigraph/repair.rs", ".dataset()", 1, WriteProtocol::ManifestAdoption), // The sixth accessor reports deferred FTS coverage from an immutable // snapshot; it only reads index metadata and never stages or publishes. - ("db/omnigraph/optimize.rs", ".dataset()", 6, WriteProtocol::Composed("Optimize v9 planning + read-only coverage status + physical cleanup")), + ("db/omnigraph/optimize.rs", ".dataset()", 9, WriteProtocol::Composed("Optimize v9 planning + read-only coverage and native-fork inventory + physical cleanup")), ("db/omnigraph/optimize.rs", ".into_dataset()", 2, OPTIMIZE_V9), ("db/omnigraph/optimize.rs", "SnapshotHandle::new(", 1, OPTIMIZE_V9), ("exec/merge.rs", "SnapshotHandle::new(", 5, MERGE_V9), @@ -887,6 +873,7 @@ const DURABLE_PRIMITIVES: &[&str] = &[ "confirm_schema_apply_sidecar_v9(", "confirm_ensure_indices_sidecar_v9(", "delete_sidecar(", + ".commit(", ".commit_staged_create_exact(", ".commit_staged_exact(", ".commit_staged(", @@ -930,6 +917,7 @@ const DURABLE_PRIMITIVES: &[&str] = &[ ".create_branch(", ".delete_branch(", ".delete_branch_with_expected(", + ".replace_metadata(", ".force_delete_branch(", "TableStore::create_empty_dataset(", "TableStore::append_or_create_batch(", diff --git a/crates/omnigraph/tests/helpers/mod.rs b/crates/omnigraph/tests/helpers/mod.rs index 91a24382..7f2a6903 100644 --- a/crates/omnigraph/tests/helpers/mod.rs +++ b/crates/omnigraph/tests/helpers/mod.rs @@ -130,6 +130,31 @@ pub async fn open_dataset_head(uri: &str, branch: Option<&str>) -> lance::Datase } } +/// Open a physical HEAD using an exact native ref, without name discovery. +pub async fn open_dataset_head_exact(uri: &str, branch: Option<&str>) -> lance::Dataset { + let dataset = lance::dataset::builder::DatasetBuilder::from_uri(uri) + .with_session(test_session()) + .load() + .await + .unwrap(); + match branch { + Some(native) => dataset.checkout_branch(native).await.unwrap(), + None => dataset, + } +} + +/// Open the physical HEAD selected by a published graph table entry. +pub async fn open_published_dataset_head( + db: &Omnigraph, + logical: &str, + table_key: &str, +) -> lance::Dataset { + let snapshot = snapshot_branch(db, logical).await.unwrap(); + let entry = snapshot.dataset(table_key).unwrap(); + let uri = format!("{}/{}", db.uri().trim_end_matches('/'), entry.dataset_path); + open_dataset_head_exact(&uri, entry.native_dataset_branch.as_deref()).await +} + /// Whether `name` is `{logical}` itself or `{logical}.{ULID}` — one /// incarnation of the logical branch. pub fn is_incarnation_of(name: &str, logical: &str) -> bool { @@ -157,11 +182,24 @@ pub fn is_incarnation_of(name: &str, logical: &str) -> bool { /// bug — fail loudly rather than pick one. pub async fn native_ref_for(ds: &lance::Dataset, logical: &str) -> Option { let branches = ds.list_branches().await.unwrap(); - let mut lives: Vec = branches - .keys() - .filter(|name| is_incarnation_of(name, logical)) - .cloned() - .collect(); + let mut lives = Vec::new(); + for (name, contents) in branches { + if let Some(marker) = contents.metadata.get("omnigraph.retired_manifest_branch") { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Retirement { + version: u32, + native_branch: String, + identifier: lance::dataset::refs::BranchIdentifier, + } + let retirement: Retirement = serde_json::from_str(marker).unwrap(); + assert_eq!(retirement.version, 1); + assert_eq!(retirement.native_branch, name); + assert_eq!(retirement.identifier, contents.identifier); + } else if is_incarnation_of(&name, logical) { + lives.push(name); + } + } assert!( lives.len() <= 1, "ambiguous incarnations for logical branch '{logical}': {lives:?}" @@ -169,22 +207,29 @@ pub async fn native_ref_for(ds: &lance::Dataset, logical: &str) -> Option, logical: &str) { let native = native.unwrap_or_else(|| panic!("expected a fork of '{logical}', got None")); + let unique_fork = native.rsplit_once(".m").is_some_and(|(owner, suffix)| { + let Some((base, commit)) = suffix.split_once('.') else { + return false; + }; + (is_incarnation_of(owner, logical) + || owner == "fork.legacy" + || is_incarnation_of(owner, "fork")) + && base.parse::().is_ok() + && is_incarnation_of(&format!("commit.{commit}"), "commit") + }); assert!( - is_incarnation_of(native, logical), - "expected an incarnation of '{logical}', got '{native}'" + is_incarnation_of(native, logical) || unique_fork, + "expected a table fork for '{logical}', got '{native}'" ); } -/// The current incarnation's native ref of a LOGICAL graph branch, read from -/// the `__manifest` dataset's live refs. Fixtures that forge or inspect -/// per-table state colliding with the engine's own fork targets must address -/// this name, not the logical one. +/// Resolve a logical graph branch in `__manifest`. +/// Table refs come from their published entry or saved recovery pin. pub async fn graph_native_ref(root_uri: &str, logical: &str) -> String { let manifest_uri = format!("{}/__manifest", root_uri.trim_end_matches('/')); let ds = lance::dataset::builder::DatasetBuilder::from_uri(&manifest_uri) diff --git a/crates/omnigraph/tests/lance_surface_guards.rs b/crates/omnigraph/tests/lance_surface_guards.rs index 26f3bd1d..a95a13f3 100644 --- a/crates/omnigraph/tests/lance_surface_guards.rs +++ b/crates/omnigraph/tests/lance_surface_guards.rs @@ -4074,105 +4074,42 @@ async fn index_default_names_distinguish_kinds_and_replacement_is_by_name() { ); } -// --- Guard 24: second-generation shallow-clone index reads fail upstream ------ -// -// Upstream tracking: lance-format/lance#7840. Re-confirmed still present on -// Lance 9.0.0 stable (this guard passes on 9.0.0), so the free-text companion -// BTREE stays deferred. -// -// PURE-LANCE repro of the fork-lineage index bug (no omnigraph code): a -// dataset's index files are recorded via `base_paths` redirects when a branch -// is shallow-cloned, but cloning a CLONE records the redirect against the -// immediate source tree instead of composing the source's own redirect to -// where the files actually live. Every index-consuming read through the -// second-generation branch then hard-errors with `Not found: -// …/tree//_indices/…` instead of degrading. -// -// Omnigraph hits this whenever a branch-of-a-branch materializes its own -// table fork (e.g. a fast-forward `branch_merge` into a non-main target) and -// a query then probes ANY index — BTREE equality, FTS `search`. It is the -// reason the free-text companion BTREE (equality/prefix acceleration) is -// deferred: it would widen the exposure to every `@key` equality lookup. -// -// This guard asserts the BUG (like the former blob-compaction guard): it -// turns RED when a Lance bump fixes second-generation clone index reads — -// then (1) delete this guard, (2) re-land the companion-BTREE dispatch -// (`plan_index_work_node`) with its tests, and (3) re-check -// `branch_merge_into_non_main_target_works` against the dual-index truth. +/// GQT cannot inspect native index base IDs or bypass the engine adapter. #[tokio::test] -async fn second_generation_branch_index_reads_fail_upstream() { - use futures::TryStreamExt; - +async fn stock_lance_nested_clone_overwrites_inherited_index_base_issue_7840() { let dir = tempfile::tempdir().unwrap(); - let uri = dir.path().join("guard24.lance"); + let uri = dir.path().join("nested-index.lance"); let uri = uri.to_str().unwrap(); - - // Base dataset with a BTREE on `value` (the exact index-build call shape - // the engine uses). - let mut ds = fresh_dataset(uri).await; - ds.create_index_builder(&["value"], IndexType::BTree, &ScalarIndexParams::default()) + let mut root = fresh_dataset(uri).await; + root.create_index_builder(&["value"], IndexType::BTree, &ScalarIndexParams::default()) .replace(true) .await .unwrap(); - - // First-generation branch (the engine's fork call shape), plus a write so - // the branch has its own commits. - let version = ds.version().version; - let mut feature = ds.create_branch("feature", version, None).await.unwrap(); - let schema = Arc::new(Schema::new(vec![ - Field::new("id", DataType::Utf8, false), - Field::new("value", DataType::Int32, false), - ])); - let batch = RecordBatch::try_new( - schema.clone(), - vec![ - Arc::new(StringArray::from(vec!["carol"])), - Arc::new(Int32Array::from(vec![3])), - ], - ) - .unwrap(); - let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); - feature.append(reader, None).await.unwrap(); - - // An indexed read through the FIRST-generation branch works: the clone's - // base-path redirect resolves the index files in the root tree. - async fn indexed_rows(ds: &Dataset) -> lance::Result { - let mut scanner = ds.scan(); - scanner.filter("value = 1").unwrap(); - let batches: Vec = scanner.try_into_stream().await?.try_collect().await?; - Ok(batches.iter().map(|b| b.num_rows()).sum()) - } - assert_eq!( - indexed_rows(&feature).await.unwrap(), - 1, - "first-generation clone must resolve parent index files" + let version = root.version().version; + let mut first = root.create_branch("first", version, None).await.unwrap(); + let inherited = first.load_indices().await.unwrap()[0].clone(); + let version = first.version().version; + let second = first.create_branch("second", version, None).await.unwrap(); + let rewritten = second.load_indices().await.unwrap()[0].clone(); + assert_eq!(rewritten.uuid, inherited.uuid); + assert_ne!(rewritten.base_id, inherited.base_id); + let mut scanner = second.scan(); + scanner.filter("value = 1").unwrap(); + assert!( + scanner + .explain_plan(true) + .await + .unwrap() + .contains("ScalarIndexQuery") + ); + let result = match scanner.try_into_stream().await { + Ok(stream) => stream.try_collect::>().await.map(|_| ()), + Err(error) => Err(error), + }; + assert!( + result.is_err(), + "remove the engine index-origin adapter when stock Lance preserves inherited bases" ); - - // Second-generation branch: clone the clone. - let feature_version = feature.version().version; - let experiment = feature - .create_branch("experiment", feature_version, None) - .await - .unwrap(); - - // The indexed read through the second-generation clone currently fails - // with a Not-found on `tree/feature/_indices/...` — the redirect points at - // the immediate source tree, where the index files never lived. - let result = indexed_rows(&experiment).await; - match result { - Err(e) => { - let msg = e.to_string(); - assert!( - msg.contains("Not found") && msg.contains("_indices"), - "expected the known index-file Not-found failure, got: {msg}" - ); - } - Ok(n) => panic!( - "second-generation clone indexed read SUCCEEDED ({n} rows) — Lance fixed \ - clone-of-clone index base paths. Delete this guard, re-land the free-text \ - companion BTREE dispatch, and re-validate the branch-merge topology tests." - ), - } } /// Runtime guard — the rrf prefilter gate's linchpin (PR #587): BM25 scores diff --git a/crates/omnigraph/tests/lifecycle.rs b/crates/omnigraph/tests/lifecycle.rs index 7e1102eb..e155f16f 100644 --- a/crates/omnigraph/tests/lifecycle.rs +++ b/crates/omnigraph/tests/lifecycle.rs @@ -101,8 +101,8 @@ async fn init_creates_graph() { db.internal_schema_version_of(ReadTarget::branch("main")) .await .unwrap(), - 7, - "fresh graphs must use the v7 manifest format (RFC 0062 manifest clock)" + 8, + "fresh graphs must use v8 so old binaries refuse retired native refs" ); assert!(snap.dataset("node:Person").is_some()); assert!(snap.dataset("node:Company").is_some()); diff --git a/crates/omnigraph/tests/maintenance.rs b/crates/omnigraph/tests/maintenance.rs index d88f0e5f..7ba7260d 100644 --- a/crates/omnigraph/tests/maintenance.rs +++ b/crates/omnigraph/tests/maintenance.rs @@ -1996,6 +1996,46 @@ async fn cleanup_reconciles_orphaned_branch_forks() { let mut ds = Dataset::open(&person_uri).await.unwrap(); let base = ds.version().version; ds.create_branch("ghost", base, None).await.unwrap(); + let mut parent = ds + .create_branch("long-orphan-parent", base, None) + .await + .unwrap(); + parent.create_branch("z", base, None).await.unwrap(); + ds.create_branch("zombie", base, None).await.unwrap(); + std::fs::remove_file(format!("{person_uri}/_refs/branches/zombie.json")).unwrap(); + assert!(!ds.list_branches().await.unwrap().contains_key("zombie")); + let partial = std::path::Path::new(&person_uri).join("tree/partial/_transactions"); + std::fs::create_dir_all(&partial).unwrap(); + std::fs::write( + partial.join("remaining.txn"), + b"interrupted deletion residue", + ) + .unwrap(); + for directory in [ + "data", + "_versions", + "_transactions", + "_deletions", + "_indices", + ] { + let branch = format!("team/{directory}/topic"); + ds.create_branch(&branch, base, None).await.unwrap(); + let encoded = branch.replace('/', "%2F"); + std::fs::remove_file(format!("{person_uri}/_refs/branches/{encoded}.json")).unwrap(); + } + ds.create_branch("protected/data/topic", base, None) + .await + .unwrap(); + ds.tags() + .create("nested-keep", ("protected/data/topic", base)) + .await + .unwrap(); + std::fs::remove_file(format!( + "{person_uri}/_refs/branches/protected%2Fdata%2Ftopic.json" + )) + .unwrap(); + ds.create_branch("tagged", base, None).await.unwrap(); + ds.tags().create("keep", ("tagged", base)).await.unwrap(); assert!( ds.list_branches() .await @@ -2025,6 +2065,32 @@ async fn cleanup_reconciles_orphaned_branch_forks() { "cleanup should reconcile the orphaned 'ghost' fork away" ); } + for branch in [ + "ghost", + "long-orphan-parent", + "z", + "zombie", + "partial", + "team", + ] { + assert!( + !std::path::Path::new(&person_uri) + .join("tree") + .join(branch) + .exists(), + "cleanup must reclaim native tree {branch}" + ); + } + let ds = Dataset::open(&person_uri).await.unwrap(); + assert!(ds.list_branches().await.unwrap().contains_key("tagged")); + ds.tags().delete("keep").await.unwrap(); + assert!( + std::path::Path::new(&person_uri) + .join("tree/protected/data/topic") + .exists(), + "a tag must protect its ambiguous ref-absent native tree" + ); + ds.tags().delete("nested-keep").await.unwrap(); assert_eq!( count_rows(&db, "node:Person").await, people_before, @@ -2038,6 +2104,17 @@ async fn cleanup_reconciles_orphaned_branch_forks() { }) .await .unwrap(); + assert!( + !std::path::Path::new(&person_uri) + .join("tree/tagged") + .exists() + ); + assert!( + !std::path::Path::new(&person_uri) + .join("tree/protected") + .exists(), + "removing the last tag must let cleanup converge on the ambiguous root" + ); } // cleanup must reclaim a manifest-unreferenced fork even when the BRANCH is @@ -2076,13 +2153,29 @@ async fn cleanup_reconciles_live_branch_orphan_fork_but_keeps_legitimate_fork() ds.list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), + .contains_key(&feature_native), "precondition: forged orphan Person fork present on the live branch" ); } let company_uri = node_table_uri(&db, "Company").await; + let company_entry = helpers::snapshot_branch(&db, "feature") + .await + .unwrap() + .dataset("node:Company") + .unwrap() + .clone(); + let company_fork = company_entry.native_dataset_branch.as_deref().unwrap(); + let feature_companies = count_rows_branch(&db, "feature", "node:Company").await; + assert!( + Dataset::open(&company_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap() + .contains_key(company_fork) + ); let main_people = count_rows(&db, "node:Person").await; let main_companies = count_rows(&db, "node:Company").await; @@ -2100,8 +2193,7 @@ async fn cleanup_reconciles_live_branch_orphan_fork_but_keeps_legitimate_fork() !ds.list_branches() .await .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), + .contains_key(&feature_native), "cleanup must reclaim the manifest-unreferenced Person fork on the live branch" ); } @@ -2109,40 +2201,46 @@ async fn cleanup_reconciles_live_branch_orphan_fork_but_keeps_legitimate_fork() { let ds = Dataset::open(&company_uri).await.unwrap(); assert!( - ds.list_branches() - .await - .unwrap() - .keys() - .any(|name| helpers::is_incarnation_of(name, "feature")), + ds.list_branches().await.unwrap().contains_key(company_fork), "cleanup must NOT reclaim a legitimately-forked table on a live branch" ); } // main is untouched. assert_eq!(count_rows(&db, "node:Person").await, main_people); assert_eq!(count_rows(&db, "node:Company").await, main_companies); + let reopened = Omnigraph::open(db.uri()).await.unwrap(); + let after = helpers::snapshot_branch(&reopened, "feature") + .await + .unwrap(); + assert_same_dataset_entry(&company_entry, after.dataset("node:Company").unwrap()); + assert_eq!( + count_rows_branch(&reopened, "feature", "node:Company").await, + feature_companies + ); } +/// Retention crosses a published pointer switch while its graph branch remains live. +/// Native fork existence and exact historical query targets require the Rust owner. #[tokio::test] -async fn cleanup_preserves_detached_native_fork_pinned_by_lazy_child() { +async fn cleanup_age_window_preserves_recent_detached_fork_snapshot() { let dir = tempfile::tempdir().unwrap(); let mut db = init_and_load(&dir).await; - let main_companies = count_rows(&db, "node:Company").await; + let base_count = count_rows(&db, "node:Company").await; db.branch_create("feature").await.unwrap(); db.load_as( "feature", None, - r#"{"type":"Company","data":{"name":"BorrowedCo"}}"#, + r#"{"type":"Company","data":{"name":"RecentCo"}}"#, LoadMode::Merge, None, ) .await .unwrap(); - db.branch_create_from(ReadTarget::branch("feature"), "child") - .await - .unwrap(); - let before = db.snapshot_of(ReadTarget::branch("child")).await.unwrap(); - let borrowed = before.dataset("node:Company").unwrap().clone(); - + let saved = db.snapshot_of(ReadTarget::branch("feature")).await.unwrap(); + let saved_commit = omnigraph::db::SnapshotId::new(saved.graph_head(Some("feature")).unwrap()); + let saved_entry = saved.dataset("node:Company").unwrap(); + let native = saved_entry.native_dataset_branch.as_ref().unwrap().clone(); + let company_uri = node_table_uri(&db, "Company").await; assert_eq!( db.branch_merge("feature", "main").await.unwrap(), MergeOutcome::FastForward @@ -2150,7 +2248,7 @@ async fn cleanup_preserves_detached_native_fork_pinned_by_lazy_child() { db.load_as( "main", None, - r#"{"type":"Company","data":{"name":"MainCo"}}"#, + r#"{"type":"Company","data":{"name":"LaterCo"}}"#, LoadMode::Merge, None, ) @@ -2160,52 +2258,412 @@ async fn cleanup_preserves_detached_native_fork_pinned_by_lazy_child() { db.branch_merge("main", "feature").await.unwrap(), MergeOutcome::FastForward ); - let switched = db.snapshot_of(ReadTarget::branch("feature")).await.unwrap(); assert_eq!( - switched + db.snapshot_of(ReadTarget::branch("feature")) + .await + .unwrap() .dataset("node:Company") .unwrap() .native_dataset_branch, None, - "the merge from main must switch feature's Company to main's lineage, detaching its fork" ); - assert_eq!( - count_rows_branch(&db, "feature", "node:Company").await, - main_companies + 2 + let query = "query companies() { match { $c: Company } return { $c.name } }"; + let before = db + .query( + ReadTarget::snapshot(saved_commit.clone()), + query, + "companies", + &Default::default(), + ) + .await + .unwrap(); + assert_eq!(before.num_rows(), base_count + 1); + db.cleanup(CleanupPolicyOptions { + keep_versions: Some(1), + older_than: Some(Duration::from_secs(30 * 24 * 60 * 60)), + }) + .await + .unwrap(); + assert!( + Dataset::open(&company_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap() + .contains_key(&native), + "an exact endpoint inside the explicit age window must survive pointer detachment", + ); + let reopened = Omnigraph::open(db.uri()).await.unwrap(); + let after = reopened + .query( + ReadTarget::snapshot(saved_commit), + query, + "companies", + &Default::default(), + ) + .await + .unwrap(); + assert_eq!(after.num_rows(), base_count + 1); + db.cleanup(CleanupPolicyOptions { + keep_versions: Some(1), + older_than: Some(Duration::ZERO), + }) + .await + .unwrap(); + assert!( + !Dataset::open(&company_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap() + .contains_key(&native), + "an unused fork becomes reclaimable once every age observation is outside the window", ); assert_eq!( - count_rows_branch(&db, "child", "node:Company").await, - main_companies + 1 + count_rows_branch(&reopened, "feature", "node:Company").await, + base_count + 2 ); +} +/// A ref-absent path can contain both aged residue and a new descendant object. +/// Filesystem modification times and physical grouped deletion require the Rust owner. +#[tokio::test] +async fn cleanup_age_window_preserves_recent_ref_absent_descendant() { + let dir = tempfile::tempdir().unwrap(); + let mut db = init_and_load(&dir).await; + let person_uri = node_table_uri(&db, "Person").await; + let grouped = std::path::Path::new(&person_uri).join("tree/team"); + let old_file = grouped.join("data/topic/_transactions/old.txn"); + let recent_file = grouped.join("data/topic/_transactions/recent.txn"); + std::fs::create_dir_all(old_file.parent().unwrap()).unwrap(); + std::fs::write(&old_file, b"old interrupted residue").unwrap(); + std::fs::File::open(&old_file) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(std::time::SystemTime::UNIX_EPOCH)) + .unwrap(); + std::fs::write(&recent_file, b"recent interrupted residue").unwrap(); db.cleanup(CleanupPolicyOptions { - keep_versions: Some(1), - older_than: None, + keep_versions: None, + older_than: Some(Duration::from_secs(30 * 24 * 60 * 60)), + }) + .await + .unwrap(); + assert!( + old_file.exists(), + "a retained descendant protects its whole deletion prefix" + ); + assert!( + recent_file.exists(), + "a missing ref is not permission to discard a recent object" + ); + db.cleanup(CleanupPolicyOptions { + keep_versions: None, + older_than: Some(Duration::ZERO), }) .await .unwrap(); + assert!( + !grouped.exists(), + "expired ref-absent residue must still converge" + ); +} - let reopened = Omnigraph::open(db.uri()).await.unwrap(); - for handle in [&db, &reopened] { - let after = handle - .snapshot_of(ReadTarget::branch("child")) +/// Retirement of an old tree must use the updated native ref object's age. +/// Native lifecycle contents and filesystem timestamps require the Rust owner. +#[tokio::test] +async fn cleanup_age_window_preserves_recent_retirement_of_old_manifest_fork() { + let dir = tempfile::tempdir().unwrap(); + let mut db = init_and_load(&dir).await; + db.branch_create("feature").await.unwrap(); + let manifest_uri = dir.path().join("__manifest"); + let manifest = Dataset::open(manifest_uri.to_str().unwrap()).await.unwrap(); + let native = manifest + .list_branches() + .await + .unwrap() + .into_keys() + .find(|name| name.starts_with("feature.")) + .unwrap(); + let live_ref = manifest_uri + .join("_refs/branches") + .join(format!("{native}.json")); + let mut contents: serde_json::Value = + serde_json::from_slice(&std::fs::read(&live_ref).unwrap()).unwrap(); + contents["create_at"] = serde_json::json!(0); + std::fs::write(&live_ref, serde_json::to_vec(&contents).unwrap()).unwrap(); + let tree = manifest_uri.join("tree").join(&native); + let mut paths = vec![tree.clone(), live_ref.clone()]; + while let Some(path) = paths.pop() { + if path.is_dir() { + paths.extend( + std::fs::read_dir(&path) + .unwrap() + .map(|entry| entry.unwrap().path()), + ); + } else { + std::fs::File::open(path) + .unwrap() + .set_times( + std::fs::FileTimes::new().set_modified(std::time::SystemTime::UNIX_EPOCH), + ) + .unwrap(); + } + } + db.branch_delete("feature").await.unwrap(); + assert!(live_ref.exists()); + let retired = manifest.branches().get(&native).await.unwrap(); + assert!( + retired + .metadata + .contains_key("omnigraph.retired_manifest_branch") + ); + assert!( + helpers::native_ref_for(&manifest, "feature") + .await + .is_none() + ); + assert!( + std::fs::metadata(&live_ref).unwrap().modified().unwrap() + > std::time::SystemTime::UNIX_EPOCH, + "retirement refreshes the native ref timestamp even when the tree is old" + ); + db.cleanup(CleanupPolicyOptions { + keep_versions: None, + older_than: Some(Duration::from_secs(30 * 24 * 60 * 60)), + }) + .await + .unwrap(); + assert!( + live_ref.exists(), + "an old branch's recent retirement starts its explicit age window" + ); + assert!( + tree.exists(), + "the recent retirement metadata must protect the aged physical tree" + ); + db.cleanup(CleanupPolicyOptions { + keep_versions: None, + older_than: Some(Duration::ZERO), + }) + .await + .unwrap(); + assert!(!live_ref.exists()); + assert!(!tree.exists()); +} + +/// Recent immutable data protects an aged native ancestor even without a graph owner. +/// Backdated native refs and physical ancestry are observable only in the Rust owner. +#[tokio::test] +async fn cleanup_age_window_preserves_recent_native_mutation_and_aged_parent() { + let dir = tempfile::tempdir().unwrap(); + let mut db = init_and_load(&dir).await; + let person_uri = node_table_uri(&db, "Person").await; + let mut main = Dataset::open(&person_uri).await.unwrap(); + let base = main.version().version; + let mut parent = main.create_branch("aged-parent", base, None).await.unwrap(); + let mut child = parent + .create_branch("aged-child", base, None) + .await + .unwrap(); + for native in ["aged-parent", "aged-child"] { + let live_ref = std::path::Path::new(&person_uri) + .join("_refs/branches") + .join(format!("{native}.json")); + let mut contents: serde_json::Value = + serde_json::from_slice(&std::fs::read(&live_ref).unwrap()).unwrap(); + contents["create_at"] = serde_json::json!(0); + std::fs::write(&live_ref, serde_json::to_vec(&contents).unwrap()).unwrap(); + let mut paths = vec![ + std::path::Path::new(&person_uri).join("tree").join(native), + live_ref, + ]; + while let Some(path) = paths.pop() { + if path.is_dir() { + paths.extend( + std::fs::read_dir(&path) + .unwrap() + .map(|entry| entry.unwrap().path()), + ); + } else { + std::fs::File::open(path) + .unwrap() + .set_times( + std::fs::FileTimes::new().set_modified(std::time::SystemTime::UNIX_EPOCH), + ) + .unwrap(); + } + } + } + let changed = child.delete("name = 'Alice'").await.unwrap(); + assert_eq!(changed.num_deleted_rows, 1); + let latest = changed.new_dataset.version().version; + db.cleanup(CleanupPolicyOptions { + keep_versions: None, + older_than: Some(Duration::from_secs(30 * 24 * 60 * 60)), + }) + .await + .unwrap(); + let branches = Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!( + branches.contains_key("aged-child"), + "later data must override initial creation age" + ); + assert!( + branches.contains_key("aged-parent"), + "a recently mutated child protects its older ancestor" + ); + let retained = Dataset::open(&person_uri) + .await + .unwrap() + .checkout_version(("aged-child", Some(latest))) + .await + .unwrap(); + assert_eq!( + retained.branch_location().branch.as_deref(), + Some("aged-child") + ); + assert_eq!(retained.version().version, latest); + assert_eq!( + retained.count_rows(None).await.unwrap(), + count_rows(&db, "node:Person").await - 1 + ); + db.cleanup(CleanupPolicyOptions { + keep_versions: None, + older_than: Some(Duration::ZERO), + }) + .await + .unwrap(); + let branches = Dataset::open(&person_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!(!branches.contains_key("aged-child")); + assert!(!branches.contains_key("aged-parent")); +} + +#[tokio::test] +async fn cleanup_preserves_detached_native_fork_pinned_by_lazy_child() { + for child_writes in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let mut db = init_and_load(&dir).await; + let main_companies = count_rows(&db, "node:Company").await; + db.branch_create("feature").await.unwrap(); + db.load_as( + "feature", + None, + r#"{"type":"Company","data":{"name":"BorrowedCo"}}"#, + LoadMode::Merge, + None, + ) + .await + .unwrap(); + db.branch_create_from(ReadTarget::branch("feature"), "child") .await .unwrap(); - assert_same_dataset_entry(&borrowed, after.dataset("node:Company").unwrap()); + let before = db.snapshot_of(ReadTarget::branch("child")).await.unwrap(); + let borrowed = before.dataset("node:Company").unwrap().clone(); + assert_eq!( - count_rows_branch(handle, "child", "node:Company").await, - main_companies + 1 + db.branch_merge("feature", "main").await.unwrap(), + MergeOutcome::FastForward + ); + db.load_as( + "main", + None, + r#"{"type":"Company","data":{"name":"MainCo"}}"#, + LoadMode::Merge, + None, + ) + .await + .unwrap(); + assert_eq!( + db.branch_merge("main", "feature").await.unwrap(), + MergeOutcome::FastForward + ); + let switched = db.snapshot_of(ReadTarget::branch("feature")).await.unwrap(); + assert_eq!( + switched + .dataset("node:Company") + .unwrap() + .native_dataset_branch, + None, + "the merge from main must switch feature's Company to main's lineage, detaching its fork" ); assert_eq!( - count_rows_branch(handle, "feature", "node:Company").await, + count_rows_branch(&db, "feature", "node:Company").await, main_companies + 2 ); + assert_eq!( + count_rows_branch(&db, "child", "node:Company").await, + main_companies + 1 + ); + + if child_writes { + db.load_as( + "child", + None, + r#"{"type":"Company","data":{"name":"ChildCo"}}"#, + LoadMode::Merge, + None, + ) + .await + .unwrap(); + } + let expected_child = db + .snapshot_of(ReadTarget::branch("child")) + .await + .unwrap() + .dataset("node:Company") + .unwrap() + .clone(); + db.cleanup(CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + + let company_uri = node_table_uri(&db, "Company").await; + assert!( + Dataset::open(&company_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap() + .contains_key(borrowed.native_dataset_branch.as_deref().unwrap()), + "the written child still needs its ancestor's native fork" + ); + let reopened = Omnigraph::open(db.uri()).await.unwrap(); + for handle in [&db, &reopened] { + let after = handle + .snapshot_of(ReadTarget::branch("child")) + .await + .unwrap(); + assert_same_dataset_entry(&expected_child, after.dataset("node:Company").unwrap()); + assert_eq!( + count_rows_branch(handle, "child", "node:Company").await, + main_companies + 1 + usize::from(child_writes) + ); + assert_eq!( + count_rows_branch(handle, "feature", "node:Company").await, + main_companies + 2 + ); + } } } -// A fork named by a dead incarnation of a LIVE logical branch is garbage: -// the live incarnation's forks are named by its own suffix, so nothing can -// resolve to the dead one. Cleanup reclaims it and keeps the live fork. +/// Reclaims dead table forks across branch retirement and type drop/re-add. +/// Requires native ref and path inspection beyond GQT's observable results. #[tokio::test] async fn cleanup_reclaims_dead_incarnation_fork_of_live_branch() { let dir = tempfile::tempdir().unwrap(); @@ -2220,9 +2678,11 @@ async fn cleanup_reclaims_dead_incarnation_fork_of_live_branch() { ) .await .unwrap(); - let live_native = helpers::graph_native_ref(db.uri(), "feature").await; + let published = helpers::snapshot_branch(&db, "feature").await.unwrap(); + let live_entry = published.dataset("node:Company").unwrap().clone(); + let live_native = live_entry.native_dataset_branch.as_ref().unwrap(); let dead_native = "feature.01ARZ3NDEKTSV4RRFFQ69G5FAV".to_string(); - assert_ne!(live_native, dead_native); + assert_ne!(live_native, &dead_native); let companies_before = count_rows_branch(&db, "feature", "node:Company").await; let company_uri = node_table_uri(&db, "Company").await; @@ -2250,7 +2710,7 @@ async fn cleanup_reclaims_dead_incarnation_fork_of_live_branch() { "cleanup must reclaim a dead incarnation's fork while the logical branch is live" ); assert!( - branches.contains_key(&live_native), + branches.contains_key(live_native), "cleanup must keep the live incarnation's fork" ); assert_eq!( @@ -2258,6 +2718,52 @@ async fn cleanup_reclaims_dead_incarnation_fork_of_live_branch() { companies_before, "cleanup must not disturb the live branch's rows" ); + let reopened = Omnigraph::open(db.uri()).await.unwrap(); + let after = helpers::snapshot_branch(&reopened, "feature") + .await + .unwrap(); + assert_same_dataset_entry(&live_entry, after.dataset("node:Company").unwrap()); + assert_eq!( + count_rows_branch(&reopened, "feature", "node:Company").await, + companies_before + ); + + db.branch_delete("feature").await.unwrap(); + let old_tree = std::path::Path::new(&company_uri) + .join("tree") + .join(live_native); + assert!( + old_tree.exists(), + "branch deletion must defer table fork reclamation" + ); + db.apply_schema( + "node Person { name: String @key age: I32? } edge Knows: Person -> Person { since: Date? }", + ) + .await + .unwrap(); + assert!( + old_tree.exists(), + "soft drop must preserve the old physical incarnation" + ); + db.apply_schema(TEST_SCHEMA).await.unwrap(); + let replacement_uri = node_table_uri(&db, "Company").await; + assert_ne!(replacement_uri, company_uri); + db.cleanup(CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + assert!( + !old_tree.exists(), + "cleanup must discover forks of a dropped table incarnation" + ); + assert!( + Dataset::open(&company_uri).await.is_ok(), + "soft-dropped main history remains readable" + ); + assert_eq!(count_rows(&db, "node:Company").await, 0); + assert!(Dataset::open(&replacement_uri).await.is_ok()); } // Regression (iss-848): a table with rows but NULL vectors (the load-before- diff --git a/crates/omnigraph/tests/merge_fast_forward.rs b/crates/omnigraph/tests/merge_fast_forward.rs index 3ec2d3e4..0eeeff95 100644 --- a/crates/omnigraph/tests/merge_fast_forward.rs +++ b/crates/omnigraph/tests/merge_fast_forward.rs @@ -166,12 +166,10 @@ async fn append_only_fast_forward_merge_uses_fenced_insert() { assert_single_physical_publish_encloses_keyed_work(&probes); } -/// A lazy graph branch pins an immutable table version while continuing to -/// share the native main ref. Advancing main after two graph branches fork is -/// therefore not drift on either branch: first touch must fork the target from -/// its old graph pin, even though the inherited native ref's HEAD is newer. +/// A lazy target adopts the exact source endpoint after main advances. +/// A subsequent target write must fork independently from that source pin. #[tokio::test] -async fn lazy_target_ref_only_fast_forward_uses_pin_after_main_advances() { +async fn lazy_target_pointer_fast_forward_uses_pin_after_main_advances() { let dir = tempfile::tempdir().unwrap(); let uri = dir.path().to_str().unwrap(); let main = init_and_load(&dir).await; @@ -210,6 +208,8 @@ async fn lazy_target_ref_only_fast_forward_uses_pin_after_main_advances() { .await .unwrap(); + let source_before = snapshot_branch(&source, "source").await.unwrap(); + let source_entry = source_before.dataset("node:Person").unwrap(); let merger = Omnigraph::open(uri).await.unwrap(); let probes = MergeWriteProbes::default(); let outcome = with_merge_write_probes(probes.clone(), merger.branch_merge("source", "target")) @@ -219,7 +219,7 @@ async fn lazy_target_ref_only_fast_forward_uses_pin_after_main_advances() { assert_eq!( probes.stage_fenced_insert_calls(), 0, - "a lazy target adopts the source state by an exact-version native ref fork" + "a lazy target adopts the exact source ref and version" ); assert_eq!(probes.stage_merge_insert_calls(), 0); assert_eq!(probes.strict_insert_preflight_calls(), 0); @@ -234,8 +234,63 @@ async fn lazy_target_ref_only_fast_forward_uses_pin_after_main_advances() { assert!(names.iter().any(|name| name == "source-only")); assert!( !names.iter().any(|name| name == "main-after-fork"), - "first touch must fork the lazy target's pinned version, not the inherited ref's newer HEAD" + "adoption must preserve the source pin despite main advancing" ); + let adopted = snapshot_branch(&merger, "target").await.unwrap(); + let adopted_entry = adopted.dataset("node:Person").unwrap(); + assert_eq!( + ( + &adopted_entry.type_key, + &adopted_entry.dataset_path, + adopted_entry.published_dataset_version, + &adopted_entry.native_dataset_branch, + adopted_entry.entity_count, + ), + ( + &source_entry.type_key, + &source_entry.dataset_path, + source_entry.published_dataset_version, + &source_entry.native_dataset_branch, + source_entry.entity_count, + ), + "the lazy target must adopt the exact source table pointer" + ); + merger + .load( + "target", + r#"{"type":"Person","data":{"name":"target-only","age":42}}"#, + LoadMode::Append, + ) + .await + .unwrap(); + let written = snapshot_branch(&merger, "target").await.unwrap(); + assert_ne!( + written + .dataset("node:Person") + .unwrap() + .native_dataset_branch, + source_entry.native_dataset_branch + ); + let source_after = snapshot_branch(&merger, "source").await.unwrap(); + assert!( + source_after + .dataset("node:Person") + .unwrap() + .same_registration(source_entry) + ); + let reopened = Omnigraph::open(uri).await.unwrap(); + for branch in ["source", "target"] { + let names = collect_column_strings( + &read_table_branch(&reopened, branch, "node:Person").await, + "name", + ); + assert_eq!( + names.iter().any(|name| name == "target-only"), + branch == "target" + ); + assert!(names.iter().any(|name| name == "source-only")); + assert!(!names.iter().any(|name| name == "main-after-fork")); + } } /// The fast-forward validation shortcut is deliberately narrower than the @@ -289,12 +344,10 @@ node Person { ); } -/// The proven publisher re-mints the same insertion-absence certificate on -/// its target-owned transaction. A later merge must be able to consume that -/// output as one link in a longer complete source-history proof; otherwise the -/// optimization would work for only one branch generation. +/// Pointer adoption preserves the complete certified insertion history. +/// A later merge into main must still admit that history through the proven publisher. #[tokio::test] -async fn proven_fast_forward_certificate_composes_across_merge_generation() { +async fn proven_fast_forward_certificate_survives_pointer_adoption() { const SCHEMA: &str = r#" node Person { name: String @key @@ -339,15 +392,43 @@ node Person { .await .unwrap(); + let leaf_snapshot = snapshot_branch(&source, "leaf").await.unwrap(); + let leaf_entry = leaf_snapshot.dataset("node:Person").unwrap(); let first_probes = MergeWriteProbes::default(); let first = with_merge_write_probes(first_probes.clone(), source.branch_merge("leaf", "source")) .await .unwrap(); assert_eq!(first, MergeOutcome::FastForward); - assert_eq!(first_probes.stage_fenced_insert_rows(), 1); + assert_eq!(first_probes.stage_fenced_insert_rows(), 0); + assert_eq!(first_probes.stage_merge_insert_calls(), 0); + assert_eq!(first_probes.stage_append_calls(), 0); assert_eq!(first_probes.strict_insert_preflight_calls(), 0); + assert_eq!(first_probes.proven_insert_history_read_calls(), 1); assert_eq!(first_probes.ordered_cursor_scan_calls(), 0); + let adopted = snapshot_branch(&source, "source").await.unwrap(); + let adopted_entry = adopted.dataset("node:Person").unwrap(); + assert_eq!( + ( + &adopted_entry.type_key, + &adopted_entry.dataset_path, + adopted_entry.published_dataset_version, + &adopted_entry.native_dataset_branch, + adopted_entry.entity_count, + ), + ( + &leaf_entry.type_key, + &leaf_entry.dataset_path, + leaf_entry.published_dataset_version, + &leaf_entry.native_dataset_branch, + leaf_entry.entity_count, + ), + "the owning target must adopt the exact leaf table pointer" + ); + assert_eq!( + count_rows_branch(&source, "source", "node:Person").await, + base_count + 2 + ); let final_probes = MergeWriteProbes::default(); let final_outcome = @@ -356,12 +437,17 @@ node Person { .unwrap(); assert_eq!(final_outcome, MergeOutcome::FastForward); assert_eq!(final_probes.stage_fenced_insert_rows(), 2); + assert_eq!( + final_probes.proven_insert_history_read_calls(), + 2, + "each certified insertion must be read once from its owning native lineage" + ); assert_eq!(final_probes.stage_merge_insert_calls(), 0); assert_eq!(final_probes.strict_insert_preflight_calls(), 0); assert_eq!( final_probes.ordered_cursor_scan_calls(), 0, - "the second merge must accept the earlier proven publisher's certificate as part of the complete chain" + "the final merge must accept the insertion certificates preserved by pointer adoption" ); assert_eq!(count_rows(&main, "node:Person").await, base_count + 2); } @@ -409,7 +495,7 @@ async fn append_only_fast_forward_merge_uses_bounded_fenced_insert_chain() { base_entry.dataset_path.trim_start_matches('/') ); let base_table = Dataset::open(&person_uri).await.unwrap(); - let source_table = helpers::open_dataset_head(&person_uri, Some("feature")).await; + let source_table = helpers::open_published_dataset_head(&main, "feature", "node:Person").await; let base_identifier = base_table.branch_identifier().await.unwrap(); let source_identifier = source_table.branch_identifier().await.unwrap(); assert_eq!( @@ -501,11 +587,12 @@ async fn nested_source_lineage_merges_without_false_read_set_conflict() { .branch_identifier() .await .unwrap(); - let source_identifier = helpers::open_dataset_head(&person_uri, Some("experiment")) - .await - .branch_identifier() - .await - .unwrap(); + let source_identifier = + helpers::open_published_dataset_head(&main, "experiment", "node:Person") + .await + .branch_identifier() + .await + .unwrap(); assert!( source_identifier.version_mapping.len() >= base_identifier.version_mapping.len() + 2, "fixture must contain at least two native descendant hops" @@ -526,75 +613,142 @@ async fn nested_source_lineage_merges_without_false_read_set_conflict() { assert!(names.iter().any(|name| name == "nested-experiment")); } -/// Cleaned history must disable only the provenance shortcut. Immutable -/// snapshot rows remain sufficient for the bounded ordered-diff fallback, so a -/// missing intermediate Lance manifest is not a merge correctness failure. +/// Cleaned source or ancestor history disables only the provenance shortcut. +/// Immutable snapshot rows remain sufficient for the bounded ordered diff; +/// missing intermediate Lance manifests must not cause a merge failure. #[tokio::test] async fn missing_source_transaction_history_falls_back_to_ordered_diff() { - let dir = tempfile::tempdir().unwrap(); - let uri = dir.path().to_str().unwrap(); - let main = init_and_load(&dir).await; - let base_count = count_rows(&main, "node:Person").await; - main.branch_create("feature").await.unwrap(); + for ancestor_history in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let uri = dir.path().to_str().unwrap(); + let main = if ancestor_history { + let main = Omnigraph::init(uri, "node Person {\n name: String @key\n age: I32\n }") + .await + .unwrap(); + main.load( + "main", + r#"{"type":"Person","data":{"name":"base","age":30}}"#, + LoadMode::Overwrite, + ) + .await + .unwrap(); + main + } else { + init_and_load(&dir).await + }; + let base_count = count_rows(&main, "node:Person").await; + main.branch_create("feature").await.unwrap(); - let mut feature = Omnigraph::open(uri).await.unwrap(); - append_new_persons(&mut feature, "feature", 2).await; + let mut feature = Omnigraph::open(uri).await.unwrap(); + append_new_persons(&mut feature, "feature", 2).await; - let base_snapshot = snapshot_main(&main).await.unwrap(); - let base_entry = base_snapshot.dataset("node:Person").unwrap(); - let person_uri = format!( - "{}/{}", - main.uri().trim_end_matches('/'), - base_entry.dataset_path.trim_start_matches('/') - ); - let source = helpers::open_dataset_head(&person_uri, Some("feature")).await; - let missing_version = source - .version() - .version - .checked_sub(1) - .expect("source fixture must have an intermediate version"); - assert!( - missing_version > base_entry.published_dataset_version, - "fixture needs at least two source transactions above the merge base" - ); - let versions_dir = std::path::Path::new(&person_uri) - .join("tree") - .join(graph_native_ref(uri, "feature").await) - .join("_versions"); - let v1_path = versions_dir.join(format!("{missing_version}.manifest")); - let v2_path = versions_dir.join(format!("{:020}.manifest", u64::MAX - missing_version)); - let manifest_path = [v1_path, v2_path] - .into_iter() - .find(|path| path.exists()) - .expect("intermediate source manifest must exist before cleanup"); - std::fs::remove_file(&manifest_path).unwrap(); - drop(source); - drop(feature); + let base_snapshot = snapshot_main(&main).await.unwrap(); + let base_entry = base_snapshot.dataset("node:Person").unwrap(); + let person_uri = format!( + "{}/{}", + main.uri().trim_end_matches('/'), + base_entry.dataset_path.trim_start_matches('/') + ); + let source = helpers::open_published_dataset_head(&main, "feature", "node:Person").await; + if ancestor_history { + feature + .branch_create_from(ReadTarget::branch("feature"), "leaf") + .await + .unwrap(); + feature + .load( + "leaf", + r#"{"type":"Person","data":{"name":"leaf-new","age":31}}"#, + LoadMode::Append, + ) + .await + .unwrap(); + feature.branch_merge("leaf", "feature").await.unwrap(); + let adopted = + helpers::open_published_dataset_head(&main, "feature", "node:Person").await; + assert_ne!(adopted.manifest.branch, source.manifest.branch); + assert_eq!( + adopted + .branch_identifier() + .await + .unwrap() + .find_referenced_version(&source.branch_identifier().await.unwrap()), + Some(source.version().version) + ); + } + let missing_version = source + .version() + .version + .checked_sub(1) + .expect("source fixture must have an intermediate version"); + assert!( + missing_version > base_entry.published_dataset_version, + "fixture needs at least two source transactions above the merge base" + ); + let versions_dir = std::path::Path::new(&person_uri) + .join("tree") + .join(source.manifest.branch.as_deref().unwrap()) + .join("_versions"); + let v1_path = versions_dir.join(format!("{missing_version}.manifest")); + let v2_path = versions_dir.join(format!("{:020}.manifest", u64::MAX - missing_version)); + let manifest_path = [v1_path, v2_path] + .into_iter() + .find(|path| path.exists()) + .expect("intermediate source manifest must exist before cleanup"); + std::fs::remove_file(&manifest_path).unwrap(); + drop(source); + drop(feature); - let probes = MergeWriteProbes::default(); - let outcome = with_merge_write_probes(probes.clone(), main.branch_merge("feature", "main")) - .await - .unwrap(); - assert_eq!(outcome, MergeOutcome::FastForward); - assert!( - probes.ordered_cursor_scan_calls() >= 2, - "missing provenance must enter the ordered base/source fallback" - ); - assert_eq!(probes.stage_append_calls(), 0); - assert_eq!(probes.strict_insert_preflight_calls(), 1); - assert_eq!(probes.stage_fenced_insert_calls(), 1); - assert_eq!( - probes.stage_merge_insert_calls(), - 0, - "ordered-diff insert fallback must reuse the join-free StrictInsert adapter" - ); - assert_eq!(count_rows(&main, "node:Person").await, base_count + 2); + let probes = MergeWriteProbes::default(); + let outcome = with_merge_write_probes(probes.clone(), main.branch_merge("feature", "main")) + .await + .unwrap(); + assert_eq!(outcome, MergeOutcome::FastForward); + assert!( + probes.ordered_cursor_scan_calls() >= 2, + "missing provenance must enter the ordered base/source fallback" + ); + assert_eq!(probes.stage_append_calls(), 0); + assert_eq!(probes.strict_insert_preflight_calls(), 1); + assert_eq!(probes.stage_fenced_insert_calls(), 1); + assert_eq!( + probes.stage_merge_insert_calls(), + 0, + "ordered-diff insert fallback must reuse the join-free StrictInsert adapter" + ); + assert_eq!( + count_rows(&main, "node:Person").await, + base_count + 2 + usize::from(ancestor_history) + ); + let names = collect_column_strings(&read_table(&main, "node:Person").await, "name"); + assert_eq!( + names + .iter() + .filter(|name| name.as_str() == "ff_new_0") + .count(), + 1 + ); + assert_eq!( + names + .iter() + .filter(|name| name.as_str() == "ff_new_1") + .count(), + 1 + ); + assert_eq!( + names + .iter() + .filter(|name| name.as_str() == "leaf-new") + .count(), + usize::from(ancestor_history) + ); - let recovery_dir = dir.path().join("__recovery"); - assert!( - !recovery_dir.exists() || std::fs::read_dir(recovery_dir).unwrap().next().is_none(), - "successful fallback merge must remove its recovery sidecar" - ); + let recovery_dir = dir.path().join("__recovery"); + assert!( + !recovery_dir.exists() || std::fs::read_dir(recovery_dir).unwrap().next().is_none(), + "successful fallback merge must remove its recovery sidecar" + ); + } } /// When the target still equals the merge base, the ordered adopt classifier diff --git a/crates/omnigraph/tests/recovery.rs b/crates/omnigraph/tests/recovery.rs index c29d1264..03527bf2 100644 --- a/crates/omnigraph/tests/recovery.rs +++ b/crates/omnigraph/tests/recovery.rs @@ -243,7 +243,14 @@ async fn drift_guard_advice_ignores_other_branch_sidecars() { // violation; roll-forward-only mode leaves it for the next ReadWrite // open) — it persists through the write attempt below. let (person_uri, person_identity) = node_table_fixture(&db, "Person").await; - let feature_native = helpers::graph_native_ref(db.uri(), "feature").await; + let feature_native = helpers::snapshot_branch(&db, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); let sidecar_json = format!( r#"{{ "schema_version": 1, @@ -319,7 +326,14 @@ async fn deleted_branch_sidecar_does_not_wedge_writes_or_open() { // A rollback-eligible (deferred) sidecar pinned to feature — shaped // so every roll-forward-only pass leaves it on disk. let (person_uri, person_identity) = node_table_fixture(&db, "Person").await; - let feature_native = helpers::graph_native_ref(db.uri(), "feature").await; + let feature_native = helpers::snapshot_branch(&db, "feature") + .await + .unwrap() + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .clone() + .unwrap(); let sidecar_json = format!( r#"{{ "schema_version": 1, diff --git a/crates/omnigraph/tests/warm_read_cost.rs b/crates/omnigraph/tests/warm_read_cost.rs index 7b13ca0a..c36f2dee 100644 --- a/crates/omnigraph/tests/warm_read_cost.rs +++ b/crates/omnigraph/tests/warm_read_cost.rs @@ -308,10 +308,9 @@ async fn cold_other_branch_resolution_uses_one_coherent_manifest_open() { .await; } -/// Branch controls reuse a verified current view or take one coherent capture -/// on a miss. The owned source cannot change the handle's branch binding. -/// Deletion reuses a verified surviving-main view and still opens its target -/// plus the native main ref for the exact BranchIdentifier-fenced classifier. +/// Branch controls reuse a verified current view or take one coherent capture on a miss. +/// The owned source cannot change the handle's branch binding. +/// Deletion captures its target and native refs without scanning surviving table borrowers. #[tokio::test] async fn native_branch_controls_use_one_post_gate_manifest_capture() { cost_harness(async { @@ -326,7 +325,7 @@ async fn native_branch_controls_use_one_post_gate_manifest_capture() { Box::pin(assert_non_bound_branch_control_cost(&db, &mut writer)).await; Box::pin(assert_branch_control_source_incarnation(&db, &mut writer)).await; #[cfg(feature = "failpoints")] - Box::pin(assert_cached_borrower_blocks_branch_delete( + Box::pin(assert_cached_borrower_survives_branch_delete( &db, &mut writer, )) @@ -353,12 +352,11 @@ async fn assert_bound_branch_control_cost(db: &Omnigraph, writer: &mut Omnigraph assert_eq!( (delete_io.internal_open_count, delete_io.manifest_scan_count), (2, 1), - "branch delete needs one coherent target capture and one native-ref \ - opener; surviving main is already loaded and freshly verified" + "branch delete needs one coherent target capture and one native-ref opener" ); assert_eq!( - delete_io.version_probes, 1, - "dependency reuse must prove freshness" + delete_io.version_probes, 0, + "deletion does not probe surviving branch snapshots" ); db.branch_create("cold_delete").await.unwrap(); @@ -377,9 +375,10 @@ async fn assert_bound_branch_control_cost(db: &Omnigraph, writer: &mut Omnigraph stale_delete_io.internal_open_count, stale_delete_io.manifest_scan_count ), - (3, 2), - "a stale surviving-main view must fall back to its fresh manifest-only proof" + (2, 1), + "a stale surviving-main view must not add work to target deletion" ); + assert_eq!(stale_delete_io.version_probes, 0); let (created, stale_create_io) = measure(db.branch_create("main_fresh")).await; created.unwrap(); assert_eq!( @@ -485,7 +484,6 @@ async fn assert_branch_control_source_incarnation(db: &Omnigraph, writer: &mut O writer.branch_delete(child).await.unwrap(); } writer.branch_delete("feature").await.unwrap(); - writer.wait_for_fork_reclaims().await; writer.branch_create("feature").await.unwrap(); mutate_branch( writer, @@ -531,20 +529,12 @@ async fn assert_branch_control_source_incarnation(db: &Omnigraph, writer: &mut O } #[cfg(feature = "failpoints")] -async fn assert_cached_borrower_blocks_branch_delete(db: &Omnigraph, writer: &mut Omnigraph) { - // Reuse the same fixture and the existing legacy-pointer test seam. A - // sibling whose manifest was forked from main can still borrow feature's - // table ref; native ancestry alone cannot prove that deleting it is safe. +async fn assert_cached_borrower_survives_branch_delete(db: &Omnigraph, writer: &mut Omnigraph) { for branch in ["main_fresh", "review_recreated"] { writer.branch_delete(branch).await.unwrap(); } - writer.wait_for_fork_reclaims().await; writer - .failpoint_publish_table_head_without_index_rebuild_for_test( - "binding_check", - "node:Person", - Some("feature"), - ) + .branch_merge("feature", "binding_check") .await .unwrap(); db.sync_branch("binding_check").await.unwrap(); @@ -559,23 +549,25 @@ async fn assert_cached_borrower_blocks_branch_delete(db: &Omnigraph, writer: &mu ); let source = db.snapshot_of("feature").await.unwrap(); let source_entry = source.dataset("node:Person").unwrap(); + let source_native = helpers::graph_native_ref(db.uri(), "feature").await; + let source_fork = source_entry.native_dataset_branch.as_deref().unwrap(); let borrower = db.snapshot_of("binding_check").await.unwrap(); let borrowed_entry = borrower.dataset("node:Person").unwrap(); assert_eq!( borrowed_entry.native_dataset_branch, source_entry.native_dataset_branch, - "legacy sibling must retain the target's exact table ref" + "the sibling must retain the source's exact table ref" ); assert_eq!( borrowed_entry.published_dataset_version, source_entry.published_dataset_version ); - assert!(source_entry.native_dataset_branch.is_some()); + assert_eq!(borrowed_entry.entity_count, source_entry.entity_count); let table_uri = format!("{}/{}", db.uri(), source_entry.dataset_path); let table = lance::Dataset::open(&table_uri).await.unwrap(); let table_refs_before = table.list_branches().await.unwrap(); let mut before = std::collections::BTreeMap::new(); - for branch in ["main", "binding_check", "feature"] { + for branch in ["main", "binding_check"] { let snapshot = db.snapshot_of(branch).await.unwrap(); let mut entries = snapshot.datasets().collect::>(); entries.sort_by(|a, b| a.type_key.cmp(&b.type_key)); @@ -591,36 +583,55 @@ async fn assert_cached_borrower_blocks_branch_delete(db: &Omnigraph, writer: &mu } let (deleted, io) = measure(db.branch_delete("feature")).await; - let error = deleted.unwrap_err(); - assert!( - error - .to_string() - .contains("because branch 'binding_check' still depends on it"), - "must refuse at the table-borrower proof, not native ancestry: {error}" - ); + deleted.expect("table borrowers must remain valid after logical owner deletion"); assert_eq!( ( io.internal_open_count, io.manifest_scan_count, io.version_probes ), - (2, 2, 1), - "only target capture and cold-main proof may scan; the bound borrower \ - must refuse from its freshly verified cache, before the delete classifier" + (2, 1, 0), + "cached table-borrower deletion must stay within its measured control cost" ); - db.wait_for_fork_reclaims().await; + assert!( + !refs_before[&source_native] + .metadata + .contains_key("omnigraph.retired_manifest_branch") + ); + let mut refs_after = manifest.list_branches().await.unwrap(); + let retirement = refs_after + .get_mut(&source_native) + .expect("logical deletion retains the exact native ref") + .metadata + .remove("omnigraph.retired_manifest_branch") + .expect("logical deletion publishes retirement metadata"); assert_eq!( - serde_json::to_value(manifest.list_branches().await.unwrap()).unwrap(), + serde_json::from_str::(&retirement).unwrap(), + serde_json::json!({ + "version": 1, + "native_branch": source_native, + "identifier": refs_before[&source_native].identifier, + }), + "retirement must bind the exact captured native lifetime" + ); + assert_eq!( + serde_json::to_value(refs_after).unwrap(), serde_json::to_value(refs_before).unwrap(), - "refused deletion must preserve every graph branch incarnation" + "deletion must change only the target's retirement metadata" + ); + assert!( + helpers::native_ref_for(&manifest, "feature") + .await + .is_none() ); + assert!(db.snapshot_of("feature").await.is_err()); assert_eq!( serde_json::to_value(table.list_branches().await.unwrap()).unwrap(), serde_json::to_value(table_refs_before).unwrap(), - "refused deletion must preserve the borrowed native table ref" + "logical deletion must preserve every physical table ref" ); - for (branch, (version, head, pins, rows)) in before { + for (branch, (version, head, pins, rows)) in before.clone() { let snapshot = db.snapshot_of(branch).await.unwrap(); assert_eq!( snapshot.graph_manifest_version(), @@ -641,6 +652,84 @@ async fn assert_cached_borrower_blocks_branch_delete(db: &Omnigraph, writer: &mu "{branch} payload changed" ); } + writer + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + assert!( + lance::Dataset::open(&table_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap() + .contains_key(source_fork) + ); + let reopened = Omnigraph::open(db.uri()).await.unwrap(); + let borrower_after = reopened.snapshot_of("binding_check").await.unwrap(); + assert!( + borrower_after + .dataset("node:Person") + .unwrap() + .same_registration(borrowed_entry) + ); + for (branch, (_, _, _, rows)) in &before { + assert_eq!( + &helpers::read_table_branch(&reopened, branch, "node:Person").await, + rows + ); + } + + writer.branch_create("feature").await.unwrap(); + writer + .mutate( + "feature", + MUTATION_QUERIES, + "insert_person", + &mixed_params(&[("$name", "after-owner-recreation")], &[("$age", 34)]), + ) + .await + .unwrap(); + let recreated = writer.snapshot_of("feature").await.unwrap(); + let recreated_fork = recreated + .dataset("node:Person") + .unwrap() + .native_dataset_branch + .as_deref() + .unwrap(); + assert_ne!(recreated_fork, source_fork); + writer + .cleanup(omnigraph::db::CleanupPolicyOptions { + keep_versions: Some(1), + older_than: None, + }) + .await + .unwrap(); + let refs = lance::Dataset::open(&table_uri) + .await + .unwrap() + .list_branches() + .await + .unwrap(); + assert!(refs.contains_key(source_fork)); + assert!(refs.contains_key(recreated_fork)); + let reopened = Omnigraph::open(db.uri()).await.unwrap(); + let borrower_after = reopened.snapshot_of("binding_check").await.unwrap(); + assert!( + borrower_after + .dataset("node:Person") + .unwrap() + .same_registration(borrowed_entry) + ); + for (branch, (_, _, _, rows)) in before { + assert_eq!( + helpers::read_table_branch(&reopened, branch, "node:Person").await, + rows + ); + } } /// A non-main branch can be deleted and recreated at the same Lance version diff --git a/docs/dev/branch-protection.md b/docs/dev/branch-protection.md index e28e37b6..32c1a04e 100644 --- a/docs/dev/branch-protection.md +++ b/docs/dev/branch-protection.md @@ -36,7 +36,7 @@ policy change must still be applied by an administrator to affect GitHub. deliberately not required: with strict checks every merge invalidates every other open pull request's required contexts, so a required 60-minute context would space merges an hour apart. The merger waits for `Test Workspace`, -`V5 ↔ V7 Format Fence`, and both `RustFS S3 Integration` shards, all +`V5 ↔ V8 Format Fence`, and both `RustFS S3 Integration` shards, all reporting contexts, to report and reads a red result before merging. All of them run again on every push to `main`, on tags, and by dispatch, where a red run makes `main` diff --git a/docs/dev/lance.md b/docs/dev/lance.md index 80c77e61..9b7ad0d8 100644 --- a/docs/dev/lance.md +++ b/docs/dev/lance.md @@ -1,6 +1,7 @@ # Lance documentation index -**Pinned dependency:** Lance 11.0.0, complete package family +**Pinned dependency:** unmodified crates.io Lance 11.0.0, complete package +family. OmniGraph does not vendor or patch Lance. **Purpose:** required upstream reading and current OmniGraph compatibility fences @@ -162,21 +163,72 @@ history of dependency bumps. | Surface | Current fence | Test owner | |---|---|---| | File format | Every production write explicitly selects stable V2_2; experimental V2_3 is not part of the graph contract. | `lifecycle.rs`, write-site source guards | -| Graph keys | Every v6 node/edge table has exact non-null `id` as its unenforced primary key. Strict insert/upsert uses the sealed filter-bearing adapter; raw keyed Append is forbidden. | `lance_surface_guards.rs`, staged-table tests, `forbidden_apis.rs` | +| Graph keys | Current schema-v8 node/edge tables retain the exact non-null `id` unenforced primary key introduced in v6. Strict insert/upsert uses the sealed filter-bearing adapter; raw keyed Append is forbidden. | `lance_surface_guards.rs`, staged-table tests, `forbidden_apis.rs` | | Stable row IDs | Graph tables use stable row IDs; delete/update/index maintenance must retain their mapping. Overwrite allocates fresh IDs and restore retains the allocation high-water marks. Staged-view IDs are provisional, not committed identity. | `lance_surface_guards.rs`, staged-table tests, `writes.rs` | | KNN result order | A late payload-hydration plan can lose global ordering metadata, so nearest requests one final output partition. Internal reads remain parallel. | `lance_surface_guards.rs`, `search.rs` | | KNN probe budget | A `nearest` scan sets `maximum_nprobes` per index delta and widens it from Lance's execution summary (`partitions_searched` / `partitions_ranked`, read through `scan_stats_callback`); a prefilter admitting fewer rows than `k` makes Lance emit the unreached rows at `_distance = +inf`, which the engine resolves with a flat exact rescan (`use_index(false)`); `count_rows(None)` is the live row count (deletions excluded), read for the ladder's exhaustion stop and as the overfetch loop's exact-pass `k`. A renamed counter or marker turns the ladder fail-closed. | `lance_surface_guards.rs`, `search.rs` | | Full-text analyzer | Every selected FTS segment needs artifact-scoped compatibility proof. Explicit branch rebuilds replace all segments from rows; old snapshots may refuse FTS. | staged-table tests, `search.rs`, `maintenance.rs` | | Blob v2 | Null, valid empty, non-empty, selector cardinality, neighboring bytes, and 3→1 compaction are pinned on Lance 11. | `lance_surface_guards.rs`, `maintenance.rs` | | Index coverage | Indexes are derived. Rewrites and compaction may leave an uncovered tail; reads must combine indexed and scan paths until explicit reconciliation. | `scalar_indexes.rs`, `search.rs`, `maintenance.rs` | -| Branches/tags | Native refs are per dataset. Every graph-branch life is a ref named `{logical}.{ULID}` (bare names are legacy lives), so a recreated branch never shares a `tree/` path or cache key with its predecessor; the `__manifest` ref list is the branch registry. OmniGraph validates ref incarnation and coordinates graph-level authority through `__manifest`. Lance also refuses force deletion while a tag targets that native branch. | `branching.rs`, `lance_surface_guards.rs` | -| Cleanup | Lance protects native refs/tags; OmniGraph additionally computes graph-wide lazy-branch and recovery floors before invoking cleanup. | `maintenance.rs` | +| Inherited index files | An engine `CommitHandler` adapter repairs stock Lance's nested-clone index origins within the existing clone commit. Exact source metadata preserves inherited `Some(base_id)` values and assigns the immediate-source base only to source-local indexes. External fragment-reuse details are read from their original base and converted to validated inline details. Invalid details fail closed before native creation. | `storage_layer::lance_clone` tests; `lance_surface_guards.rs::stock_lance_nested_clone_overwrites_inherited_index_base_issue_7840` | +| Branches/tags | Each graph-branch lifetime is `{logical}.{ULID}`; bare names identify legacy lifetimes. Native `__manifest/_refs/branches/` entries without retirement metadata define logical branches. Deletion writes an identity-bound retirement marker through public `Branches::replace_metadata`; the same physical ref remains readable to native descendants. A native `__manifest` tag still blocks logical deletion. This does not add graph-wide tagged snapshot retention. | `branching.rs`, `lance_surface_guards.rs` | +| Cleanup | Explicit cleanup discovers table identities through canonical manifest registrations, including tombstoned registrations, before inventorying native refs and trees. It protects exact live table refs, native ancestry, path dependencies, tags, and recovery pins. Only unprotected forks and retired leaves are reclaimed before version floors apply. Unreadable live roots abort cleanup; an invalid table inventory preserves that dataset and reports the failure. Writes and branch deletion defer physical reclamation. | `maintenance.rs`, `branching.rs` | +| Table forks | First touch uses `fork.{incarnation}.m{base_version}.{commit_ULID}`, at most 80 ASCII bytes, independently of logical-name length. Legacy owners use `legacy` in the incarnation position. The existing commit ULID distinguishes attempts; registration metadata proves ownership, and source-pointer adoption preserves it. Missing legacy ownership requires exact ref equality. Naming and the existing live-only write identifier lookup add no storage requests. | `long_branch_names_first_touch.gqt`, `branching.rs`, `failpoints.rs`, metadata tests | | MemWAL | Upstream support exists, but OmniGraph's RFC 0018 and RFC 0026 experiments were removed. No stream profile, token ledger, hidden stream column, or `_mem_wal` path is current. | `lifecycle.rs`, cluster removed-field diagnostics | +The clone adapter is installed during normal dataset opens and initial dataset +creation, preserving configured commit handlers without an additional reopen. +Exact, version-pinned source context scopes native branch creation. The adapter +validates the clone target, source bases and index metadata, then delegates the +existing manifest writer and transaction to the original handler. Ordinary +commits pass through without capturing index metadata; indexed fork preparation +reads the source's immutable index section, while an absent section skips that +read. The adapter adds no separate index-object write or manifest publication. + +External fragment-reuse details are read through the index's original +`base_id`, using checked offset/size arithmetic and the actual file length. +Public typed decoding validates the payload before embedding it in the same +clone manifest. Missing, malformed or out-of-bounds details refuse native +creation. Reads, memory and the resulting inline manifest bytes scale with +that payload; this is not constant-cost metadata work. Current stable-row-ID +compaction does not produce external fragment-reuse details, but the adapter +preserves this stock Lance representation. The existing adapter tests cover +successive mixed-base clones, cold full-text/vector queries, and local and +inherited external fragment-reuse origins. + +Schema v8 defines native-ref retirement metadata. Normal open requires v8; +qualified v6/v7 graphs have explicit offline routes to it. The v7 → v8 handler +changes only manifest configuration metadata and does not infer fork ownership +or retire branches. Source v6/v7 graphs with reserved retirement metadata refuse; +current v8 no-op admission validates markers and counts only live logical refs +while retaining physical ancestors. Older binaries must not expose retired refs +as live branches. This stamp is separate from recovery-sidecar protocol versions. +See [versioning](versioning.md). + +Stock `Branches::get` and `list` include every physical ref. OmniGraph's logical +manifest helpers validate and filter `omnigraph.retired_manifest_branch`, a +version-1 JSON value binding the exact native name and identifier. An absent +marker means live; malformed, unsupported, or mismatched metadata fails closed. +The metadata update publishes retirement without removing native ancestry. +It preserves unrelated metadata and resolves a lost acknowledgement by reading +back the exact marker. Native branch controls rely on OmniGraph's existing +process-local serialization. Cold logical enumeration reads retained refs too; +cached named-write admission checks the existing ref lookup without an added +request. Cleanup reclaims only unneeded physical leaves. + +`--keep N` bounds version pruning within retained datasets; it does not count +graph commits or retain unused forks indefinitely. `--older-than` also gates +whole-fork collection using tree and native ref object timestamps before +closing over dependencies. Any recent object retains the fork, including a +fresh retirement metadata update over an old tree. Ref metadata listings for this age +check occur only during cleanup with an explicit age policy. + ## Dependency bump checklist 1. Fetch every full page in every affected domain. -2. Inspect the complete upstream tag/source and dependency delta. +2. Inspect the complete upstream release/source and dependency delta. Re-audit + the public API assumptions behind every engine compatibility adapter; + preserve unmodified crates.io dependencies. 3. Run `lance_surface_guards` first; a red guard is a required design review, not a test to weaken. 4. Run focused write, merge, search, maintenance, Blob, branch, and recovery diff --git a/docs/dev/merge.md b/docs/dev/merge.md index 33724088..32b7267b 100644 --- a/docs/dev/merge.md +++ b/docs/dev/merge.md @@ -37,16 +37,18 @@ An optimization miss is not a merge failure. Missing transaction history, unknown certificate fields, or an unfamiliar Lance shape falls back to the general route. -On the adopt route a source on main is adopted as a pointer switch onto main's -lineage, even when the target branch owns a table ref and whatever the numeric -versions say: the route is chosen by ownership shape, and the registration -carries the manifest version (RFC 0062). The owned ref is detached, not -written to. Children that pinned it keep reading it, because -reclamation counts every live branch's pins; the former owner's next -first-touch write on that table reports `detached native lineage` instead of -recreating the ref. A source on a branch merging into a target that owns the -table applies its delta onto the target's ref; an empty delta keeps the -complete target entry. +On the adopt route, every named target selects the source's exact table ref, +version, and metadata, including when the target already owns a fork. Main +keeps its data-delta route for a source on a named ref. Numeric table versions +are not compared across refs: the publication's manifest version orders the +registration within the graph branch (RFC 0062). + +The source's owner metadata stays with an adopted pointer. A later target +write creates a unique target-owned fork from the accepted source version. +Old target forks remain available to other branches and native descendants; +explicit cleanup reclaims only forks outside that protection set. Pointer +adoption still computes any required validation delta and runs the shared +constraint evaluator. ## Proven insertion route @@ -116,7 +118,7 @@ versions, and `C` the bounded publish chunks. | Route | Classification | Publication | |---|---|---| -| Pointer adoption | Metadata-only when no validation delta is needed; otherwise the delta may require base/source ordered scans | Native-ref or manifest-pointer change; no row copy | +| Pointer adoption | Metadata-only when no validation delta is needed; otherwise the delta may require base/source ordered scans | Manifest-pointer change; no table fork or row copy | | Proven insertion replay | Walk `K <= 1,024` transaction records and scan only the certified source interval | `C <= 1,024` join-free fenced inserts; no target ID preflight or MergeInsert join | | Adopt with delta | At least two full ordered scans, base and source | New rows use preflighted fenced inserts; changed rows use update-only `KnownPresentUpdate`; deletes are chunked | | General three-way | Lineage candidate discovery and filtered reads when proven; otherwise at least three full ordered scans | New rows use preflighted fenced inserts; existing rows use `KnownPresentUpdate`; deletes are chunked | @@ -204,8 +206,8 @@ ordered-cursor counts do not measure. Other useful signals are: - high `ProvenInsertHistory` means the retained transaction walk dominates; - high `ProvenInsertPlanScan` means scanning or materializing the certified source interval dominates; -- any ordered cursor on an insert-only merge means the provenance proof missed - and classification fell back; +- ordered cursors on a named-target pointer adoption can be required for + validation; on a main-target insert replay they indicate a provenance miss; - high `KeyedStage` means target lookup/join or Blob materialization dominates; - high `ManifestPublish` with a tiny delta points to manifest history or CAS retries; @@ -233,9 +235,11 @@ structured result to its public 409 representation. ## Publication and recovery -All productive table routes feed one BranchMerge recovery sidecar. Pointer -changes, table effects, target authority, and pre-minted graph lineage are fixed -before the first effect. The target becomes visible through one manifest CAS. +A merge with physical table effects uses one BranchMerge recovery sidecar. +Its complete intended delta includes pointer-only siblings as well as the +physical effects. Unique first-touch names and owners are fixed in that sidecar +before native creation. A merge containing only pointer changes needs no table +effect sidecar. Both routes publish the target through one manifest CAS. After recovery arm, a failed table link or publish retains recovery ownership and returns `RecoveryRequired`. Merge does not re-run semantic classification diff --git a/docs/dev/recovery.md b/docs/dev/recovery.md index 8d45e6f0..7f5c3a5b 100644 --- a/docs/dev/recovery.md +++ b/docs/dev/recovery.md @@ -12,13 +12,13 @@ commit protocol, not an offline repair convenience. Active writers emit identity-aware recovery sidecar schema **v9**. Manifest schema and recovery schema are independent version spaces; the current -manifest is v7. +manifest is v8. Every owned table slot carries: - non-zero stable table and incarnation identity; - diagnostic alias and physical dataset URI; -- graph and physical branch identity; +- graph and physical branch identity, including the intended table-fork owner; - expected manifest-visible Lance version; - the planned transaction or bounded maintenance outcome; - fixed manifest delta and graph lineage where applicable. @@ -78,8 +78,8 @@ the graph quiescent, Full recovery may: - roll a complete owned effect set forward; - restore/compensate an owned partial set to the pinned graph state; -- remove an owned unpublished first-touch ref or dataset, unless a live branch - pins it; +- retire recovery ownership of a proven unpublished private first-touch fork, + leaving its storage for explicit cleanup; - promote or discard owned schema staging; - refuse an invariant violation or ambiguous effect. @@ -137,13 +137,32 @@ schema contract or racing delayed cleanup. ## Graph branch controls Native branch create/delete residue is different from a data-table effect. -When `BranchContents` proves a ref absent, an unreferenced clone-only tree can -be reclaimed as derived state. A sidecar owning a real graph-table effect may +An unreferenced clone-only tree is reclaimable only when no physical +`BranchContents` exists, including a logically retired native ref. A sidecar owning a real graph-table effect may not be discarded merely because its target branch was deleted; the complete -effect/compensation proof still applies. Because every branch life has its own -native ref, a recreated branch never becomes the target of a stale sidecar's -fork: the dead life's native name resolves to nothing, and its forks are -orphans for `cleanup`. +effect/compensation proof still applies. Each first-touch table effect names +its prepared unique native ref. Recovery never substitutes a newly generated +name or a recreated logical branch. Physical pin owners and confirmed table +metadata preserve the captured owner through roll-forward and rollback. + +Private unreachable forks may remain after recovery retires their sidecars. +Explicit cleanup still proves that live table pins, recovery, tags, and Lance +ancestry no longer require them before deletion. An old owner's absence from +the logical branch list alone is not proof. + +Graph deletion writes the reserved `omnigraph.retired_manifest_branch` +metadata value through Lance's public `Branches::replace_metadata`. That +single native-ref update removes logical authority while preserving the exact +physical ref and unrelated metadata. The versioned marker binds the native +name and identifier; unknown fields, versions, or mismatched identity fail +closed. A lost acknowledgement is classified by reading the same ref and +validating its retirement marker. Absence is not proof of completed retirement. +Native history remains readable for descendants, while branch-name reads and +writes require an unretired ref. The existing process-local branch controls +serialize this read/replace protocol; they are not distributed fencing. +Explicit cleanup alone reclaims retired lifetimes after proving their native +descendants, paths, tags and current table references no longer need them. +The v8 storage fence keeps older binaries from exposing retired branches. ## Maintenance boundary diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 332d1a5f..56c3eb64 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -88,13 +88,22 @@ Server suites are organized by public route: `auth_policy`, `data_routes`, `sche CLI suites own their named planes: cluster lifecycle, data commands, stored queries, schema/config, cross-version rebuild, embedded/remote parity, and local/remote system journeys. Keep `OMNIGRAPH_HOME` hermetic by using `tests/support::cli()` or `cli_process()`. -The cross-version rebuild owner, `crossversion_upgrade.rs`, skips each predecessor case when its binary is not configured, so a local `cargo test -p omnigraph-cli --test crossversion_upgrade` is green even while CI's `V5 ↔ V7 Format Fence` is red. To run the fence locally, build the predecessor CLI from the commit `ci.yml` pins as `FINAL_INTERNAL_V5_COMMIT` (`git worktree add `, then `cargo build --locked -p omnigraph-cli --bin omnigraph` inside it) and run the exact case with that binary: +The cross-version rebuild owner, `crossversion_upgrade.rs`, skips each predecessor case when its binary is not configured, so a local `cargo test -p omnigraph-cli --test crossversion_upgrade` is green even while CI's `V5 ↔ V8 Format Fence` is red. To run the fence locally, build the predecessor CLI from the commit `ci.yml` pins as `FINAL_INTERNAL_V5_COMMIT` (`git worktree add `, then `cargo build --locked -p omnigraph-cli --bin omnigraph` inside it) and run the exact case with that binary: ```bash -OMNIGRAPH_V5_BIN=/target/debug/omnigraph cargo test --locked -p omnigraph-cli --test crossversion_upgrade current_v7_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v7 -- --exact --nocapture +OMNIGRAPH_V5_BIN=/target/debug/omnigraph cargo test --locked -p omnigraph-cli --test crossversion_upgrade current_v8_refuses_and_rebuilds_genuine_v5_and_v5_refuses_v8 -- --exact --nocapture ``` -The older seams work the same way with released binaries: `OMNIGRAPH_OLD_BIN` (0.7.2) and `OMNIGRAPH_PREVIOUS_BIN` (0.8.1). `OMNIGRAPH_V6_BIN` (the 0.10.0 release) owns the v6↔v7 fence that RFC 0062 introduced. The v0.9 journey is a different case, a fully exercised v6 graph — branches, edges, vectors, full-text and blobs — that the current binary refuses and that is rebuilt from a 0.9 export; `Test Workspace` runs both on every pull request with the releases it installs. +The older seams work the same way with released binaries: `OMNIGRAPH_OLD_BIN` (0.7.2) and `OMNIGRAPH_PREVIOUS_BIN` (0.8.1). `OMNIGRAPH_V6_BIN` (the 0.10.0 release) owns the v6↔v8 fence. RFC 0062 introduced v7's registration clock; RFC 0042's native-ref retirement metadata requires the current v8 stamp. The v0.9 journey is a different case, a fully exercised v6 graph — branches, edges, vectors, full-text and blobs — that the current binary refuses and that is rebuilt from a 0.9 export; `Test Workspace` runs both on every pull request with the releases it installs. + +The separate `Storage Upgrade Compatibility` CI job requires genuine v0.9 and +v0.10 local standalone journeys through the default v6 → v7 → v8 route. It fails +missing predecessor binaries, missing cases and skipped required cases. Engine +storage-upgrade tests own direct v7 → v8 conversion, exact pending v6 → v7 +recovery before composition, explicit target 7, deferred check reporting, and +current v8 no-op admission with retained retired refs. Keep the normal-open +format fences: explicit conversion does not grant serving support for v6/v7. +See the [support matrix](versioning.md#storage-upgrade-support-matrix). The system tests start workspace binaries on ephemeral localhost ports. Set `OMNIGRAPH_SKIP_SYSTEM_E2E=1` only in constrained local sandboxes; CI's configured owners must not skip. diff --git a/docs/dev/versioning.md b/docs/dev/versioning.md index 16615943..0c31ca82 100644 --- a/docs/dev/versioning.md +++ b/docs/dev/versioning.md @@ -16,9 +16,9 @@ version axes. Never derive one axis from another. ## Current storage contract -Normal graph open and new writes require **internal manifest schema v7**. +Normal graph open and new writes require **internal manifest schema v8**. `INTERNAL_MANIFEST_SCHEMA_VERSION` and `MIN_SUPPORTED_INTERNAL_SCHEMA_VERSION` -are both 7. +are both 8. - v4 was the last released pre-identity format, used by OmniGraph 0.8.x. - v5 was an unreleased development format that introduced SchemaIR v2, @@ -32,17 +32,26 @@ are both 7. the `__manifest` version that wrote the row, and a table's current registration is the one with the greatest manifest version, not the greatest per-native-ref Lance version. +- v8 preserves v7's registration clock and adds logical retirement metadata + on native graph refs ([RFC 0042](../rfcs/0042-incarnation-suffixed-branch-refs.md)). + Branch deletion retains exact native parent history while removing logical + branch authority. Older binaries do not interpret this metadata and could + expose retired branches; they must refuse v8 graphs before reading or + reclaiming their storage. Qualified v7 graphs have an explicit metadata-only + upgrade to v8. - the unreleased v7–v19 stamps of the rejected MemWAL experiment never shipped - and were never migration inputs; v7 is reused by RFC 0062. A graph stamped - v8–v19 by that experiment is refused as a future stamp; the refusal's - "upgrade omnigraph" advice cannot be satisfied for it, and such a graph is - rebuilt from an export taken with the build that wrote it. + and are not supported migration inputs. Reuse of a numeric stamp by another + design does not make an experimental graph compatible. Such graphs require + export with the build that wrote them and rebuild at a fresh root. Normal open refuses lower and higher stamps before recovery or table decoding. -`omnigraph upgrade` explicitly converts supported standalone v6 graphs to v7. -It preserves original retained snapshots and decodes their v6 registrations -explicitly after main-root admission. A pending upgrade marker refuses normal -opens until every branch validates and main activation completes. +`omnigraph upgrade` defaults to v8: qualified standalone v6 graphs run the +registered v6 → v7 → v8 route, and qualified v7 graphs run v7 → v8. +Original retained snapshots remain unchanged; historical v6 registrations use +an explicit legacy decoder after main-root admission. A pending upgrade marker +refuses normal opens until every branch validates and main activation completes. +Explicit `--to-format 7` retains the intermediate target for a v7-compatible +executable; the current binary still refuses normal open of that result. See [RFC 0064](../rfcs/0064-explicit-storage-upgrades.md) for the offline protocol. ## Recovery version @@ -56,8 +65,10 @@ or lower it to match v6. See [recovery.md](recovery.md). ## Lance contract -The workspace resolves the complete Lance package family to **11.0.0** and -explicitly writes stable data storage version **V2_2**. A dependency bump alone +The workspace resolves the unmodified crates.io Lance package family to +**11.0.0** and explicitly writes stable data storage version **V2_2**. +Engine adapters and compatibility boundaries are documented in +[lance.md](lance.md). A dependency bump alone does not change the OmniGraph manifest format. Adopting a new Lance file format or a behavior that changes persisted graph meaning does. @@ -66,9 +77,14 @@ Current compatibility fences and the required upstream reading set are in ## Registered conversion and rebuild fallback -The registered v6-to-v7 handler appends manifest metadata and retains table -files, branch ancestry, commit IDs and historical locators. Operators must stop -all writers and maintenance and preserve a restorable backup before execution. +The registered v6-to-v7 handler converts registration metadata; v7-to-v8 only +changes manifest configuration metadata. Both retain table files, branch +ancestry, commit IDs and historical locators. A pending v6-to-v7 attempt keeps +its original protocol, target and ownership until it completes; a request for +v8 then runs the next handler. Read-only checks of v6-to-v8 report output-dependent +checks for the second handler as deferred, and execution runs them before its +effects. Operators must stop all writers and maintenance and preserve a +restorable backup before execution. Cluster-managed conversion is refused until its admission protocol is qualified. Other source formats still require export with the source executable, fresh @@ -86,12 +102,18 @@ required cases fail the job. The binaries are version-checked before fixture creation. Branch naming without a post-fork logical-name witness is refused; see the [admission limits](../user/operations/upgrade.md). -| Source executable / format | Normal open | Explicit route | Required case in `crossversion_upgrade.rs` | +| Source executable / format | Normal open | Default explicit route | Required coverage owner | |---|---|---|---| -| 0.9.0 / v6 | Refused | v6 → v7 | `genuine_v09_explicit_storage_upgrade_preserves_history` | -| 0.10.0 / v6 | Refused | v6 → v7 | `genuine_v010_explicit_storage_upgrade_preserves_history` | -| Current / v7 | Accepted | Already-current no-op | Both migration journeys, after conversion | -| Older or unknown / not v6 or v7 | Refused | No route; source-compatible export/rebuild | Existing format fences and engine refusal tests | +| 0.9.0 / v6 | Refused | v6 → v7 → v8 | `crossversion_upgrade.rs::genuine_v09_explicit_storage_upgrade_preserves_history` | +| 0.10.0 / v6 | Refused | v6 → v7 → v8 | `crossversion_upgrade.rs::genuine_v010_explicit_storage_upgrade_preserves_history` | +| Qualified development / v7 | Refused | v7 → v8 | Engine storage-upgrade tests: metadata-only conversion, history and retry | +| Current / v8 | Accepted | Already-current no-op | Both predecessor journeys after conversion; engine retired-ref admission tests | +| Older, future or unqualified experimental format | Refused | No route; source-compatible export/rebuild | Existing format fences and engine refusal tests | + +Source v6/v7 admission rejects any reserved native-ref retirement metadata. +Current v8 no-op admission validates retirement markers and excludes valid +retired refs from the logical branch census while preserving physical ancestry. +An active v7-to-v8 attempt cannot be resumed as target v7. These journeys cover local standalone roots. Object-store backend qualification and deployment branch-protection configuration require their own environment diff --git a/docs/dev/writes.md b/docs/dev/writes.md index c543593a..9d373d69 100644 --- a/docs/dev/writes.md +++ b/docs/dev/writes.md @@ -154,33 +154,38 @@ or add a separate migration publisher. See ## First-touch tables and lazy branches -A named graph branch may inherit a main-table version without owning a native -table ref. The first write stages against the inherited snapshot, records the -intended ref/table ownership in recovery, and creates the physical branch only -inside the protected effect window. Recovery may delete only a ref or dataset -whose exact creation it owns. Table forks are named by the branch's native ref; -sidecar table pins carry that native name while the sidecar's `branch` stays -logical. - -Reclamation checks the current table pins of every live graph branch, not just -the fork's original owner. A detached native ref can still hold a child's -accepted snapshot. Cleanup and recovery retain such refs, and a first-touch -writer refuses to recreate them before arming recovery. A first-touch merge -classifies a pre-existing target native ref the same way: a ref another branch -pins is refused as detached lineage, a ref a pending operation claims or whose -liveness cannot be verified is refused as a conflict, and an orphan is deleted -before the operation arms, on the write path as on the merge path, so the -armed fork starts from a clean name and a crash between arming and forking -leaves nothing recovery must explain. The deletes run table by table before -the refusal check of the next table, so a refusal may follow a completed -delete; nothing referenced the deleted ref, so no state is lost. This liveness view is derived under the -control gates and is not persisted as another authority. -Deletion derives native refs and descendants from one registry listing. It -reuses already loaded borrower snapshots only when their native ref matches -that listing and their manifest incarnation matches a fresh probe; other -branches use the bounded manifest-only proof. Unreadable candidates still -prevent deletion. This reduces repeated work under the existing gates without -changing their scope. +A named graph branch can read an exact table version owned by another branch. +Its first write stages against that inherited snapshot and prepares a fresh +native name, `fork.{owner incarnation ULID}.m{base manifest version}.{graph commit ULID}`. +A legacy owner without an incarnation uses `legacy` in that position. +The name has at most 80 ASCII bytes, independent of the logical branch name; +the existing unique commit ID separates attempts across legacy owners. +The base manifest version and graph commit ID already belong to the captured +attempt. Name construction adds no storage request, version reservation, or +rename. The existing recovery sidecar persists the exact name and owner before +the native fork is created from the captured source ref and version. + +`TableVersionMetadata.table_fork_owner` records ownership in the existing +manifest metadata. Existing owned writes retain the actual physical ref; +pointer adoption preserves the source owner. Missing owner metadata uses only +exact native-ref equality for legacy ownership. Parsing a name cannot establish +ownership, because older legal names can resemble the new spelling. + +First-touch writes and merges leave old forks alone. A new attempt gets a new +commit ID, while recovery of the same intent reuses its saved name. Correctness +gates, exact effect identity, and baseline checks remain in place. The name's +base version is preparation context; the successful manifest publication orders +the new registration within that graph branch. + +Explicit `cleanup` protects every live table endpoint, relevant recovery, tags, +and native ancestry before reclaiming unused forks. Branch deletion starts no +background table reclamation. Branch deletion records retirement metadata on +its exact native `__manifest` ref, so descendants keep their physical parent +history while the logical name becomes unavailable. Cleanup reclaims unused +retired leaves after proving their dependencies. Cached write admission checks +the ref's identifier and retirement metadata through the existing lookup, with +no additional storage request. Cold branch enumeration includes retained refs +and filters retirement metadata; explicit cleanup reclaims unneeded refs. Stable table/incarnation identity, not `table_key`, determines whether a registration, rename, tombstone, pointer, or recovery effect belongs to the diff --git a/docs/releases/v0.11.0.md b/docs/releases/v0.11.0.md index 8a869e20..2bdda8fb 100644 --- a/docs/releases/v0.11.0.md +++ b/docs/releases/v0.11.0.md @@ -4,24 +4,29 @@ Notes accumulate here until the release is cut. ## Highlights -- **Graph storage moves to internal manifest schema v7, with explicit offline - upgrades for qualified v6 graphs.** Every `__manifest` registration row is now keyed by the - manifest version that wrote it, and a branch's view of a table is the +- **Graph storage moves to internal manifest schema v8, with explicit offline + upgrades for qualified v6 and v7 graphs.** Every `__manifest` registration row + is now keyed by the manifest version that wrote it, and a branch's view of a table is the registration with the greatest manifest version rather than the greatest per-native-ref Lance version. A branch merge that adopts a source table whose Lance version number is at or below the target's now registers the pointer switch and reads the adopted rows; before, such a merge reported `fast_forward` and kept stale rows (lower) or failed with `table version N already exists ... with different state` (equal). That error - no longer exists. `omnigraph upgrade --check --to-format 7` checks a - standalone graph before in-place conversion. Qualified migration retains + no longer exists. `omnigraph upgrade --check --to-format 8` checks a + standalone graph before in-place conversion. The default target is v8: + v6 uses the registration conversion to v7 followed by a metadata-only v8 + step; v7 uses that final step directly. A v6 check reports checks requiring + the intermediate v7 output as deferred. Qualified migration retains branches, commit history and snapshots without copying table payloads; ambiguous branch identities and cluster-managed roots refuse. Writers and maintenance must be stopped and a verified backup retained. Export/load remains the fallback for unsupported inputs and does not retain history; see [upgrade](../user/operations/upgrade.md) and - [RFC 0062](../rfcs/0062-manifest-version-clock.md), the sole reason for this - release's storage-format change. + [RFC 0062](../rfcs/0062-manifest-version-clock.md). The same storage fence + also prevents older binaries from exposing logically deleted branches whose + native history is retained for descendants + ([RFC 0042](../rfcs/0042-incarnation-suffixed-branch-refs.md)). - **Managed cluster creation, config upload and retirement.** `cluster create` requests an empty cluster, `cluster push` conditionally prepares referenced @@ -120,45 +125,37 @@ Notes accumulate here until the release is cut. key, so the type shows only in the Arrow IPC result schema. - **Repeated branch controls reuse freshly verified views.** Forking can reuse - an already loaded source, and deletion shares its branch listing and reuses - verified borrower views. Successful local content publication retains its + an already loaded source. Successful local content publication retains its coherent in-memory projection after lineage adoption. Stale views and failed handoffs keep the full reconstruction path. These changes add no persistent state or storage-format change; cold opens and publisher validation still depend on retained history. -- **Parent merges preserve snapshots inherited by child branches.** Cleanup - and first-touch recovery check all live branch references before reclaiming - a table fork. Previously, cleanup or a later parent write could remove a - fork still used by a child. Merging main into a branch points each - table that only main changed at main's history; while a child pins the - branch's former history, the branch's next write to that table fails with `detached native - lineage` before recovery is armed instead of destroying the child's rows. - Create a replacement branch from the affected branch to continue writing - while preserving existing children; merging that replacement back into the - affected branch is refused the same way until every such child has been - deleted. A first-touch write or merge whose target ref is a leftover no - branch uses drops that ref before recovery is armed, on the write path as - on the merge path, so a failure at that step never leaves a recovery - record the next open cannot resolve. +- **Branches can keep writing after adopting another table history.** A + first-touch write creates fresh table storage and preserves older snapshots + that other branches still need. The previous `detached native lineage` + refusal and replacement-branch workaround are removed. A merge into a named + branch adopts eligible source table snapshots without copying rows, including + when the target already owns table storage. Merge validation still runs, and + a source on a named branch merging into main retains the existing data-delta + route. -- **Branch merge and delete use less redundant storage work.** General merges - retain the distinction between new IDs and updates, deletion candidates use - bounded physical-offset reads, and insert-history proofs read transactions - without opening every historical dataset. Manifest decoding releases each - Arrow batch after reduction. Writes to a branch other than the handle's - current branch avoid reloading manifest history just to create commit - metadata; merges also reuse the captured target before the fresh publication - check. Branch deletion overlaps bounded dependency reads and table - reclamation while retaining the existing control gates. - A first-touch merge classifies an existing unowned target ref before arming - recovery: one another branch still uses is refused as detached lineage, one - a pending operation claims or whose liveness cannot be verified is refused - as a conflict, and an orphan is dropped before the merge arms recovery, so a - crash mid-merge never leaves a ref recovery cannot explain. - Previously, such a ref could leave an ambiguous recovery record that - prevented reopening the graph. - These changes retain Lance 11 and the current graph and recovery formats. +- **Table-fork reclamation runs during explicit cleanup.** Branch deletion + starts no background table-reclaim task; writes and merges leave unused + former forks for `cleanup`. Cleanup preserves live table snapshots and the + native ancestry they depend on. Deleting a parent now retires its logical + name while descendants retain its exact native manifest history. Cleanup + reclaims retired native lifetimes only after their dependencies disappear. + `optimize` continues to maintain layout + and index coverage; it does not collect unused forks. + +- **Branch merges use less redundant storage work.** General merges retain + the distinction between new IDs and updates, deletion candidates use bounded + physical-offset reads, and insert-history proofs read transactions without + opening every historical dataset. Manifest decoding releases each Arrow + batch after reduction. Writes to a branch other than the handle's current + branch avoid reloading history just to create commit metadata; merges reuse + the captured target before the fresh publication check. - **Two projections producing one result column name are refused at compile time (`T25`).** `return { $a.num1 as number, $a.num2 as number }` diff --git a/docs/rfcs/0040-system-column-namespace.md b/docs/rfcs/0040-system-column-namespace.md index 0dd51813..96cc757c 100644 --- a/docs/rfcs/0040-system-column-namespace.md +++ b/docs/rfcs/0040-system-column-namespace.md @@ -7,7 +7,7 @@ implementation: in-progress authors: - azimafroozeh created: 2026-08-23 -updated: 2026-09-08 +updated: 2026-09-09 discussion: https://github.com/ModernRelay/omnigraph/issues/529 supersedes: [] superseded_by: [] @@ -420,14 +420,14 @@ of the unified write protocol (RFC 0022): a `SchemaApply` intent is persisted in the `__recovery/` sidecar before the first effect, and the effects run in this order. -1. The `__manifest` internal-schema stamp on main advances from 7 to 8, by a +1. The `__manifest` internal-schema stamp on main advances from 8 to 9, by a schema-metadata commit on the `__manifest` dataset (today only init writes the stamp, inside its Create commit), recorded in the intent with the dataset version before and after, so the publish in (3) validates its read set against the post-stamp version. It publishes no graph content (RFC 0022 §3.5), and internal system branch refs keep their forked stamp, which - the publisher's per-branch guard accepts within {7, 8}. Every binary + the publisher's per-branch guard accepts within {8, 9}. Every binary that predates this RFC refuses the graph at its next open from here on, and a process already holding it refuses its next publish (`guard_stamp` in `crates/omnigraph/src/db/manifest/migrations.rs`, run by the @@ -557,16 +557,20 @@ identity from field IDs is answered the same way. ## Compatibility and reversibility +The proposed stamp is provisionally 9 after RFC 0042's schema v8. Recheck the +next available stamp when this draft is activated. + Two fences keep a new-vintage graph away from binaries that predate this RFC, and they act at different depths. The `__manifest` internal-schema -stamp advances from 7 to 8 on every new-vintage graph, at creation or as +stamp advances from 8 to 9 on every new-vintage graph, at creation or as the upgrade's first effect; `refuse_if_internal_schema_unsupported` reads it as the first object-store read of both open modes, before the recovery sweeps a read-write open runs, so every binary that predates this RFC, -whether it reads v6 (0.9.x, 0.10.x) or v7 (the 0.11.x line, RFC 0062), +whether it reads v6 (0.9.x, 0.10.x), development v7, or v8 +(the 0.11.x line, RFCs 0042 and 0062), refuses the graph before it can write anything, with the existing ceiling refusal ("`__manifest` is stamped at internal -schema v8 but this binary expects v7", from `refuse_if_stamp_unsupported` in +schema v9 but this binary expects v8", from `refuse_if_stamp_unsupported` in `crates/omnigraph/src/db/manifest/migrations.rs`; a v6 reader names v6), whose remedy is the newer binary, never a rebuild. The feature set in the schema IR is @@ -580,10 +584,10 @@ physical columns are a storage-format change (`docs/dev/versioning.md` §Changing an axis) and because the publisher's per-branch `guard_stamp` is the one gate a process already holding the graph re-runs, where the envelope check does not run. -Old-vintage graphs stay stamped 7 permanently, as they never gain +Old-vintage graphs stay stamped 8 permanently, as they never gain `system-columns`: the stamp is a storage-format fence, not a migration floor, -so `MIN_SUPPORTED_INTERNAL_SCHEMA_VERSION` stays 7 while -`INTERNAL_MANIFEST_SCHEMA_VERSION` becomes 8, and this RFC's binary is the +so `MIN_SUPPORTED_INTERNAL_SCHEMA_VERSION` stays 8 while +`INTERNAL_MANIFEST_SCHEMA_VERSION` becomes 9, and this RFC's binary is the first to serve two stamps. That retires the single-version contract stated in `crates/omnigraph/src/db/manifest/migrations.rs` (its module doc, the sub-floor refusal text, `release_for_internal_schema_version`, and the @@ -592,7 +596,7 @@ format per binary", including its export-binary table), in `docs/dev/versioning.md` (the storage row of its policy table, §Current storage contract, and §Changing an axis), and in the doc comment on `refuse_if_internal_schema_unsupported` in -`crates/omnigraph/src/db/manifest.rs` (every branch at CURRENT): 7 is the +`crates/omnigraph/src/db/manifest.rs` (every branch at CURRENT): 8 is the one stamp this binary can upgrade in place, through the explicit operation rather than an open-time dispatcher. Rollout step 2 owns those rewrites. @@ -717,7 +721,7 @@ The gates this RFC owns, each stated beside the behavior that defines it: node object), and a graph with no schema apply keeps its IR bytes and hash identical to today's (Per-graph role resolution). - Refusal: the {2, 4} generation (today's main) refuses an old-vintage - `ir_version` 5 graph, stamped 7, with the existing hard "unsupported + `ir_version` 5 graph, stamped 8, with the existing hard "unsupported ir_version" error at `refuse_unsupported_schema_versions`, before any write; the 2-only generation never reaches the IR, refusing at the stamp (Early fence); a set-carrying graph @@ -773,7 +777,7 @@ The gates this RFC owns, each stated beside the behavior that defines it: graphs). - Upgrade effects: after the upgrade every table spells `__id`/`__src`/ `__dst`, the IR carries `system-columns` at `ir_version` 5, `_schema.pg` - constraint references read `@id`/`@src`/`@dst`, the stamp reads 8, and a + constraint references read `@id`/`@src`/`@dst`, the stamp reads 9, and a query valid before the upgrade returns the same rows after it (The upgrade). - Upgrade recovery: under the DST harness, a crash at every schema-apply @@ -819,8 +823,8 @@ per-test enumeration. 2. Resolution and admission (the draft implementation, #548): system columns resolve by role through the accepted vintage, live and historical (Historical reads), new graphs admit under the prefix rule, - spell `__id`/`__src`/`__dst`, and stamp `__manifest` 8 (this binary - serves {7, 8}), the meta-field namespace lands in `.gq` and in `.pg` + spell `__id`/`__src`/`__dst`, and stamp `__manifest` 9 (this binary + serves {8, 9}), the meta-field namespace lands in `.gq` and in `.pg` constraint references, the wire envelope moves the identity beside `type`/`edge` on export and load, and the versioning machinery ships whole: the feature-set field, {2, 4, 5} acceptance (4 with the @@ -855,7 +859,7 @@ per-test enumeration. one-writer boundary being operator-owned (`docs/dev/control-plane.md` §Concurrency); servers do not hot-reload, so the restart is one the control plane already requires. Completion is the - stamp reading 8 in `omnigraph snapshot` (which opens read-write and + stamp reading 9 in `omnigraph snapshot` (which opens read-write and completes a pending roll-forward first) and the new-vintage spellings in `GET /schema`'s system-column field, and the boot-time registry check validates the stored queries against the @@ -963,3 +967,7 @@ None. rejected on the grounds #593 gave for 3: a merged binary already stamps each (main since #593 and #686), and a number once stamped is never reinterpreted. + +- 2026-09-09: RFC 0042 native retirement uses internal schema v8. This draft + provisionally serves {8, 9}, with 9 reserved for its new-vintage storage + meaning; historical stamp choices in earlier decision entries are unchanged. diff --git a/docs/rfcs/0042-incarnation-suffixed-branch-refs.md b/docs/rfcs/0042-incarnation-suffixed-branch-refs.md index a142b2bf..b451ef03 100644 --- a/docs/rfcs/0042-incarnation-suffixed-branch-refs.md +++ b/docs/rfcs/0042-incarnation-suffixed-branch-refs.md @@ -7,7 +7,7 @@ implementation: complete authors: - OmniGraph maintainers created: 2026-08-30 -updated: 2026-08-30 +updated: 2026-09-09 discussion: https://github.com/ModernRelay/omnigraph/issues/562 supersedes: [] superseded_by: [] @@ -22,14 +22,25 @@ Every life of a graph branch owns its own native Lance ref, named `{logical}.{ULID}`. The user-facing branch name stays the only public identity; the suffix is minted at `branch_create` and never shown, accepted, or addressed through a public entry point. Because a recreated branch lives at a -new native ref, it never shares a `tree/` path, a table fork, or a session -cache entry with its dead predecessor. This is the trick already used for table -incarnations, applied to branches. +new native ref, its owned `tree/` paths and session-cache keys cannot alias +those of its dead predecessor. -The Lance ref list of the `__manifest` dataset remains the only authority for -branch existence. No registry row, transaction, or recovery step is added: -resolving a logical name is listing the refs and taking the single live -incarnation. +Each first-touch table fork has a separate name: +`fork.{owner incarnation ULID}.m{base manifest version}.{graph commit ULID}`. +Legacy owners without an incarnation use `legacy` in that position. The name +has at most 80 ASCII bytes, including a 20-digit manifest version. Logical +names and their URL encoding cannot lengthen table refs or exhaust the local +filesystem's temporary-filename limit. The base version and commit ID come +from the existing write attempt. Building the name +adds no storage request, and the existing recovery sidecar persists it before +fork creation. The name distinguishes attempts; the published manifest version +orders registrations within each graph branch (RFC 0062). + +Native ref metadata in the `__manifest` dataset is the authority for branch +existence. Deletion marks the exact native ref retired through stock Lance's +public metadata API. That ref preserves physical parent history for surviving +branches. Resolving a logical name validates the retirement metadata and takes +the single unretired incarnation; it does not use a second registry. ## Motivation @@ -58,16 +69,24 @@ life's identity in the path makes the staleness unrepresentable instead. Cedar. Ordinary dotted names such as `release.1.2` stay legal. - The existing ancestor/descendant rule on logical names (`review` and `review/alice` cannot coexist) is unchanged. -- `branch delete` acknowledges the manifest publication, which is the real - commit. Physical reclaim of the old life's forks is eventual: `cleanup` - reconciles any fork whose native name is not a live incarnation, including a - fork left by a delete whose cleanup step failed. A late-settling delete can - only touch the dead life's path, which nothing references. +- `branch delete` writes retirement metadata on the exact native manifest + ref, removes its logical authority, and leaves native storage for explicit + `cleanup`. Deletion works when descendants still depend on that manifest's + history. No background reclaim task is started. +- `cleanup` proves which exact table refs remain needed by live snapshots, + recovery, tags, and Lance ancestry before reclaiming unused forks. Absence + of the original logical owner is not sufficient deletion authority. +- A merge can switch a named target to the source's exact table ref and + version, including when the target already owns a fork. Its next write can + create a new unique fork while older borrowers keep their pinned rows. - `native_dataset_branch` in dataset entries (HTTP snapshot responses) now carries the suffixed native name. It was already a physical detail field. -- Existing branches keep their bare ref name; a bare ref is a legacy - incarnation whose native name equals its logical name. No migration or format - stamp is needed. The first delete/recreate of such a branch mints a suffix. +- The name resolver recognizes legacy bare refs, whose native name equals the + logical name. Name interpretation needs no separate stamp; native retirement + requires internal manifest schema v8. Qualified v6/v7 graphs use the explicit + offline routes in [RFC 0064](0064-explicit-storage-upgrades.md); other formats + retain export/rebuild guidance. + Deleting and recreating a branch always mints a fresh suffix. ## Design @@ -80,15 +99,51 @@ life's identity in the path makes the staleness unrepresentable instead. carried on the `ManifestCoordinator`, `GraphCoordinator`, and `Snapshot` as `native_branch`. Zero live incarnations is `BranchNotFound`; two or more is a typed conflict that names both and asks for `cleanup`, never a guess. -- Table forks copy the manifest branch's native name. Write paths, first-touch - forks, EnsureIndices, branch merge target naming, sidecar table pins, and the - test-only publish seam all address forks by native name. Sidecar `branch`, - write-queue keys, gates, lineage intents, and policy scopes stay logical; - sidecar shape validation compares a pin's logical form to the sidecar branch. -- The orphan reconciler and `classify_fork_ref` compare fork names against the - live *native* set, so a dead incarnation's fork is an orphan even while its - logical name is live. -- A warm handle whose native ref is gone re-resolves the logical name through +- `table_fork_name` uses only the captured owner's incarnation suffix, + base graph-manifest version, and pre-minted graph commit ID. A legacy owner + uses the fixed `legacy` marker; the unique graph commit ID separates attempts + across legacy owners, and table datasets provide separate ref namespaces. + No logical name or path component is copied into the table ref. The existing + sidecar pins that exact name before any native fork effect. Resuming the + same intent reuses its saved name, including older spellings; a new attempt + receives a new commit ID. +- `TableVersionMetadata.table_fork_owner` records the captured native owner + in the existing registration metadata. Physical sidecar pins retain the + intended owner, and confirmed metadata and rollback publications preserve + it. Pointer adoption copies the source metadata without changing its owner. + Namespace metadata roundtrips the field as `omnigraph.table_fork_owner`. +- Ownership is read from that metadata, never inferred from the fork's + spelling. With absent metadata, only exact native-ref equality preserves + legacy ownership. An older legal name resembling the new format remains a + borrowed ref. Names do not establish stable table identity. +- Existing owned writes keep the accepted physical fork ref. First-touch + Mutation, Load, EnsureIndices, and row-writing merges create their prepared + unique refs from exact inherited versions. They perform no orphan scan or + reclamation to allocate a name. Sidecar `branch`, write-queue keys, gates, + lineage intents, and policy scopes stay logical. +- Native retirement uses public `Branches::replace_metadata` to set + `omnigraph.retired_manifest_branch` on the existing `_refs/branches/` entry. + The version-1 JSON marker binds `native_branch` and the full `identifier`. + Unknown fields, unsupported versions, or identity mismatches fail closed. + This single metadata update publishes deletion and preserves unrelated + metadata. A lost acknowledgement is resolved by exact marker readback; + missing physical authority does not prove completed retirement. +- Stock Lance `get` and `list` still expose every physical ref for ancestry + and maintenance. Engine logical helpers validate retirement metadata and + select unretired entries. Cached write admission checks the same identifier + lookup's metadata at the existing request count. Cold logical enumeration + includes retained refs. Writes do not reclaim storage. +- Explicit cleanup derives exact live table references and closes over native + ancestry before deleting a fork. Retired manifest refs are reclaimed only as + unused leaves after tag, lineage, and physical-path dependencies are checked. + Required parents survive even after their original graph branch is deleted. + Unpublished private forks and unused former forks remain until maintenance. + A count-only policy prunes retained dataset versions and permits collection + of unused forks; it does not count graph commits. Explicit `older_than` also + retains a fork if any tree or native ref object is recent, before + closing over dependencies. A retirement metadata update extends this grace + period for an old tree. These ref metadata listings occur only in age-based cleanup. +- A warm handle whose native ref is retired or gone re-resolves the logical name through the ref list: a recreated branch yields the replacement's identity (a guaranteed mismatch), a deleted one yields `BranchNotFound`. Change-feed and Blob live-branch reads map a vanished named fork to their existing @@ -105,24 +160,37 @@ life's identity in the path makes the staleness unrepresentable instead. publishes through the manifest; no new authority is introduced. - Invariant 3 (one coherent view) is strengthened: a captured native name can only ever mean one life. -- Invariant 5 (recovery) is unchanged: create is one native-ref step; delete - residue is derived garbage on a dead path. +- Invariant 5 (recovery): the prepared fork name and owner are durable before + creation. Effect classification and compensation remain exact; leaving + unreachable private forks for cleanup does not publish them. - Invariant 6 (stable identity) is honored: nothing infers identity from the logical name; the suffix is the branch-life identity in the path. -- Invariant 11 (bounded hot-path work): a branch-bound open lists the ref - registry once — cost scales with the number of live branches, not history. -- Invariant 12 (one source of truth): the ref list stays the registry. The - deny-list item "maintained parallel truth" is exactly what this design avoids. +- Invariant 11 (bounded hot-path work): cached named-write admission uses the + existing exact ref read. Cold branch enumeration reads all retained physical + refs and filters retirement metadata; cleanup reclaims unneeded refs. + This is an explicit cost limitation of retaining stock Lance refs. +- Invariant 12 (one source of truth): native ref metadata is the logical + registry. Retired refs preserve exact physical identity and ancestry without + granting logical liveness or maintaining another branch-status projection. ## Compatibility and reversibility -- Internal manifest schema v6 and recovery sidecar schema v9 are unchanged. -- Legacy bare refs resolve unchanged; suffixed refs are ordinary Lance branch - names, so an older binary can still open and read them by native name, but - must not run branch lifecycle operations against a suffixed graph. -- Reverting is a code change: suffixed refs remain valid Lance refs, and a - reverted binary would treat each as a distinct legacy branch named by its - full native name. +- Unique table forks add optional ownership metadata to existing registration + and recovery payloads. Their names introduce no allocation object or graph + publication. The manifest-version clock remains governed by RFC 0062. +- Native retirement introduces versioned native-ref metadata and requires + strict internal manifest schema v8. Older binaries must refuse because they + would expose retained physical refs as live graph branches. + Qualified v7 graphs use RFC 0064's metadata-only v7-to-v8 handler, preserving + physical history. Source retirement metadata refuses rather than being + reinterpreted as valid v8 state. +- Absent owner metadata keeps only the exact legacy ownership rule. A binary + that does not understand the new ownership contract is not a supported + writer for graphs using unique table forks. +- Legacy bare and suffixed refs remain valid native names. Graph binaries that + predate schema v8 are unsupported readers and writers of v8 graphs. +- Reverting across the retirement storage boundary requires export and rebuild; + merely restoring an older binary would lose the retirement-metadata contract. ## Alternatives @@ -136,20 +204,28 @@ life's identity in the path makes the staleness unrepresentable instead. naming break. Rejected on liability. - **Clearing session caches on lifecycle events**: cannot reach other handles, other processes, or in-flight loaders. Rejected. -- **Quarantine or phased deletes**: adds phases and state to reason about for a - problem the path already solves. Rejected. +- **Logical status rows or a retirement work queue**: rejected. Native ref + metadata supplies branch authority and preserves the physical lifetime in + one object. There is no queue whose completion determines liveness. Only + the native-retirement portion of + RFC 0058 is implemented here; merged-ancestry retention and historical snapshot + guarantees remain draft work. ## Evidence and tests - `branching.rs`: delete/recreate yields distinct native names, the recreated fork is named by the new incarnation, listing shows logical names, incarnation-shaped names are refused on create and read. -- `maintenance.rs`: cleanup reclaims a dead incarnation's fork while the logical - branch is live and keeps the live fork. +- `maintenance.rs`: cleanup must preserve exact live endpoints and their + native ancestors while reclaiming unused forks, including after same-name + recreation and a child's first write. - `failpoints.rs`: a lost-ack fork delete followed by recreate leaves the dead fork as garbage that the write never heals in place or reads; cleanup reclaims it. Existing recovery, merge, and change-feed ABA owners were converted to address forks by native name. +- `long_branch_names_first_touch.gqt`: long ASCII, hierarchical, and Unicode + logical names remain writable and preserve main's rows. `branch_names` unit + tests pin the 80-byte physical-name bound at `u64::MAX`, including legacy owners. - `lance_surface_guards.rs`: suffixed names are valid and path-disjoint. - `omnigraph-dst`: the reborn-branch cache-poison repro (seed 9401 standalone and the seed 10133 wide face) run as regression pins. @@ -165,13 +241,22 @@ compatibility fence table, and the release note. ## Unresolved questions -None for acceptance. Follow-ups out of scope: a name-only ref listing to drop -the per-ref GET on branch-bound opens; a sweeper for a dead life's manifest -tree when a delete crashed between ref removal and tree removal (inert garbage -today). +Follow-ups outside this implementation: a name-only live-ref listing to drop +the per-ref GET on branch-bound opens; RFC 0058's merged-ancestry retention and +historical snapshot guarantees. Explicit cleanup owns retired manifest trees. ## Decision log - 2026-08-30: Accepted by the maintainers as the fix for #562 over the registry-row design in PR #578; comparison recorded in the Alternatives section. +- 2026-09-09: Implemented unique table-fork names and explicit ownership in + `TableVersionMetadata.table_fork_owner`. Named-target adoption preserves the + source owner and switches exact pointers; first-touch writes do not reclaim + old forks. Explicit cleanup owns unused-fork reclamation. +- 2026-09-09: Native retirement uses identity-bound metadata on stock Lance + refs; no Lance dependency is vendored or patched. Internal manifest schema + v8 fences older binaries. Explicit cleanup preserves required parents and + collects unused retired leaves. Unique table names use bounded incarnation + and commit components. Cached named-write admission adds no storage request; + cold branch enumeration includes retained retired refs. diff --git a/docs/rfcs/0058-retained-merged-ancestry.md b/docs/rfcs/0058-retained-merged-ancestry.md index 758465c7..0455163d 100644 --- a/docs/rfcs/0058-retained-merged-ancestry.md +++ b/docs/rfcs/0058-retained-merged-ancestry.md @@ -13,7 +13,6 @@ supersedes: [] superseded_by: [] blocked_on: - Exact retention-policy treatment of retired native lifetimes - - Bounded live-branch lookup with accumulated retired native refs - Lance lifecycle-metadata, tag, cleanup, and recovery substrate evidence - Strict-format activation and compatibility evidence --- @@ -37,10 +36,12 @@ if that exact base was reclaimed, it returns a typed history gap. It never selects an older readable base to conceal the gap. The proposal does not disable `cleanup --keep` or age limits on live histories. -No production format, branch protocol, or retention behavior is activated by -this draft. In particular, the pinned Lance API currently lists retired and -live refs together. The physical-access gate below must be solved before -activation; filtering that history-sized list in Rust is not a solution. +Complete merged ancestry and descriptor-based snapshot continuity remain +unactivated. Native logical retirement is implemented separately by RFC 0042 +in internal schema v8: identity-bound metadata marks stock native refs retired +while preserving their physical ancestry. Cold logical branch enumeration +filters the retained ref list; cached named-write admission uses its existing +exact ref lookup. The broader cost gate below remains a draft requirement. ## Motivation @@ -72,11 +73,10 @@ table-head lookup, not ancestry or physical pins. Neither its draft status nor the no-go result of [RFC 0025](0025-checkpoint-retention.md) authorizes a new retention format here. -[RFC 0042](0042-incarnation-suffixed-branch-refs.md) currently makes live native -manifest refs the branch registry and treats a removed incarnation's trees as -garbage. This proposal would amend that lifecycle rule: a ref can be retired -and remain physical retention state. It keeps one branch authority rather -than adding a competing registry in the main manifest. +[RFC 0042](0042-incarnation-suffixed-branch-refs.md) makes live native manifest +refs the branch registry and now marks retired lifetimes in native metadata +while preserving them for descendants. This proposal extends historical endpoint availability and +ancestry metadata; it does not add a competing registry in main's manifest. ## User and operational behavior @@ -89,7 +89,7 @@ than adding a competing registry in the main manifest. cleanup has reclaimed a required endpoint. A new `feature` has a different incarnation. - A branch-name read or write against the deleted branch fails. Retirement - does not expose an archived branch as a writable branch, and does not add an + does not expose a retired branch as a writable branch, and does not add an internal-ref addressing API. - Snapshot and diff authorization continues to use the commit's authored logical branch and existing policy actions. Retention does not grant access @@ -179,43 +179,36 @@ gap. Defining recursive virtual merge bases is outside this proposal. ### Native lifecycle authority -The `__manifest` Lance branch ref remains the only authority for a logical -branch incarnation. Its metadata gains a versioned lifecycle payload with -the logical name, immutable fork head, and `live` or `retired` state. The -native `BranchIdentifier` remains the substrate lifetime witness. Native -names retain RFC 0042's incarnation suffix. Unrecognized lifecycle versions -fail closed; readers do not interpret a retired incarnation as a legacy live -branch. - -Creation has a recoverable preparation phase: persist a fixed control intent, -create the uniquely named native clone through Lance, capture and persist its -exact identifier, then publish complete `live` metadata through the public -native API. An incomplete ref is not publicly visible. Recovery classifies it -against the exact intent and existing native create-completion proofs. A ref -without valid lifecycle metadata and without provable preparation ownership -is an error, not permission to erase a possibly live tree. - -Deletion validates and settles relevant recovery while holding the supported -schema, branch, and table control envelope. It persists an intent containing -the expected identifier and exact before/after metadata, then changes `live` -to `retired`. That native metadata change is the logical publication. The -operation does not call Lance branch deletion or force-delete merely because -the logical name has disappeared. Lost acknowledgements are classified by -fresh metadata and identity; ambiguous or foreign changes fail closed. - -Retirement invalidates mutable branch handles. Every branch-bound reopen and -write authority check must distinguish a retired incarnation from a live -one even when its table versions did not change. Immutable descriptor-based -reads may still open the retired native ref directly. Recreated names resolve -only their new `live` incarnation; duplicate live incarnations remain an -error. Native metadata replacement preserves unrelated metadata keys. - -Lance 11 exposes `Branches::replace_metadata`, but it performs an unconditional -put. This proposal therefore retains the documented single-writer-process -branch-control boundary and must prove its recovery envelope. It does not -advertise a distributed CAS or fencing guarantee. Stronger multi-writer -lifecycle semantics require separate substrate evidence and a reviewed -protocol. +Native logical retirement is implemented independently by +[RFC 0042](0042-incarnation-suffixed-branch-refs.md), with strict internal +schema v8. This does not activate the complete ancestry or immutable snapshot +descriptor promises of this RFC. + +The `__manifest` ref in `_refs/branches/` remains the sole physical authority +for a logical incarnation. Stock Lance's public `Branches::replace_metadata` +sets `omnigraph.retired_manifest_branch`, a version-1 JSON value containing the +exact native name and identifier. That single update publishes logical deletion +while preserving physical history and unrelated metadata. Unsupported versions, +unknown fields and conflicting identities fail closed. + +Stock `get` and `list` include retained physical refs. Engine logical helpers +validate the marker and select only unretired refs. A named writer checks +retirement through its existing identifier lookup; no additional normal-write +request is introduced. Cold logical enumeration reads retained retired refs +as well. A recreated logical name resolves to a fresh native ref, while the +old captured handle cannot write through its retired authority. + +The existing schema, branch and table control envelope settles recovery before +retirement. The update checks the captured identifier and preserves unrelated +metadata; a lost acknowledgement is classified by reading the exact marker +back from the same physical ref. These controls retain the documented single +mutation-process boundary; read/replace is not distributed fencing. + +Physical deletion remains Lance-owned. Retired refs still participate in stock +descendant, tag and path checks. Cleanup closes the live root set over native +dependencies, then reclaims unreferenced retired leaves. +The descriptor, explicit-fork-head and ancestry-closure additions above still +need their own acceptance and recovery evidence before activation. ### Reachability and physical retention @@ -305,10 +298,10 @@ reclamation; it is not evidence that old damage has been repaired. ## Compatibility and reversibility -This is a new durable capability even though Lance can store the metadata -fields. Activation requires a strict internal format and recovery-version -boundary, with the concrete numbers allocated only when acceptance and release -scope are known. An old binary must refuse before branch control or cleanup +Complete ancestry is a further durable capability beyond v8 native retirement. +Its activation requires another reviewed internal-format and recovery-version +boundary, with concrete numbers allocated when acceptance and release scope +are known. An old binary must refuse before branch control or cleanup can erase retired history. No mixed-format fallback treats missing descriptors or lifecycle metadata as valid retained history. @@ -385,8 +378,11 @@ result cardinality or a wall-clock mean alone is insufficient: count native list/GET/scan work, bytes, decoded rows, and high-water memory separately from setup and verification. With fixed live working set, growing retired history must not introduce a positive lookup-I/O slope. A failed required cell blocks -activation, as in RFCs 0024 and 0025. The pinned `list_branches` API currently -enumerates every ref and reads its contents, so this gate is presently open. +activation, as in RFCs 0024 and 0025. The separately implemented native +retirement uses stock Lance refs, so cold logical enumeration currently grows +with retained retired refs. It does not satisfy this draft's stronger lookup +cost goal. Complete ancestry still needs the cost and acceptance cells in +this matrix. Upstream surfaces surveyed for this draft: complete Lance branch/tag format, tags-and-branches guide, quickstart versioning, and table versioning pages; @@ -415,8 +411,6 @@ on older RFC substrate claims. - How does existing count retention apply to a retired native lifetime, and when can its final physical tree be reclaimed without silently weakening that policy? Indefinite all-ancestor retention is not the default. -- What public Lance access shape will keep live branch lookup bounded as - retired refs accumulate? The current API has not met this requirement. - What explicit work and storage budgets should bound closure import and retirement metadata enumeration at the supported deployment scale? Values must come from the decision instruments before acceptance. diff --git a/docs/rfcs/0062-manifest-version-clock.md b/docs/rfcs/0062-manifest-version-clock.md index 180de1d4..a952cecc 100644 --- a/docs/rfcs/0062-manifest-version-clock.md +++ b/docs/rfcs/0062-manifest-version-clock.md @@ -7,7 +7,7 @@ implementation: in-progress authors: - azimafroozeh created: 2026-09-07 -updated: 2026-09-08 +updated: 2026-09-09 discussion: null supersedes: [] superseded_by: [] @@ -105,21 +105,22 @@ publish on a branch is written on the row. publishes nothing (`AdoptPublish::Nothing`). A write whose pinned registration now wins on another native ref is refused as `ReadSetChanged` (`native_ref:`). -- The graph storage stamp moves from v6 to v7. Per the storage policy - (`docs/dev/versioning.md`: strict single version, rebuild across an - incompatible change) a v6 graph is refused on open with the export and rebuild - guidance, and this binary is refused by a newer stamp. A binary at the new - stamp refuses a v6 graph before reading or writing any table and names the - export binary; a v6 binary refuses a new-stamp graph with `upgrade omnigraph`. - Neither refusal changes the graph. -- The rebuild is per branch: `omnigraph export --branch ` once per live - branch, each stream loaded into its own graph with `init` and `load`. Branch +- The registration-clock design introduced graph storage schema v7 after v6. + The combined implementation uses v8 for RFC 0042's retirement metadata while + retaining v7's row-key meaning. The storage policy requires strict v8 normal + open, with explicit registered conversion or export/rebuild for older inputs. + [RFC 0064](0064-explicit-storage-upgrades.md) composes qualified standalone + v6-to-v7 registration conversion with metadata-only v7-to-v8 conversion. + Normal open refuses v6/v7 before table decoding; older binaries refuse the + new stamp. Neither refusal changes the graph. +- The export/rebuild fallback is per branch: `omnigraph export --branch ` + once per live branch, each stream loaded into its own graph with `init` and `load`. Branch topology, shared ancestry, commit history and historical snapshots are not carried over (`docs/user/operations/upgrade.md`). The cutover quiesces writers, finishes recovery on the old binary before the export, builds the new graph at a parallel root, and never runs a mixed fleet against either root. A - deployment that needs its branches waits for the converter (unresolved - question 1). + qualified deployment can instead use RFC 0064's offline converter to preserve + branch topology and retained history. ## Design @@ -280,28 +281,26 @@ rows are not written by a publish and carry `GENESIS_MANIFEST_VERSION`. `manifest_rows_batch` verifies every key with `manifest_version_from_object_id`; the scan projection list is unchanged. -### Merge keeps its arms; the fold now honours them - -`plan_adopted_source_state` (`exec/merge.rs:3650`) keeps its arms. When the -target does not own the registration it reads, the adopt forks and registers the -fork ref, as today. When the source registration is on the root lineage -(`native_dataset_branch: None`), an adopt at a lower or equal `table_version` -registers the pointer -`(source.published_dataset_version, None)`, and the projection now selects it -whatever the target's previous number was. -[PR #630](https://github.com/ModernRelay/omnigraph/pull/630)'s -`adopt_requires_target_lineage` and the routing it selects are removed once this -RFC lands (Rollout). `reregisters_current_entry` (`merge.rs:3714`) compares the -physical fields as today, `native_dataset_branch` among them, so equal rows on a -different native ref are not a re-registration and the pointer switch is -published. - -A pointer switch off a branch's own fork ref orphans that ref, and the branch's -next first-touch write pays `reclaim_orphaned_fork_and_refork` -(`table_store.rs:1296`, `table_ops.rs:1481`). The delta route of -[PR #630](https://github.com/ModernRelay/omnigraph/pull/630) never orphans a -ref, so this is the one job that route did which the clock does not do. This RFC -accepts the cost. +### Merge publishes exact source endpoints + +When a table's target equals the merge base, a named target adopts the source's +exact `native_dataset_branch`, published table version, and version metadata. +This includes a target that currently owns a different fork. The source table +version may be below, equal to, or above the previous target table version; +the new registration's manifest version makes it authoritative. + +Main retains its target-lineage delta route for a source on a named ref. +`reregisters_current_entry` still compares the complete physical registration, +so an unchanged exact endpoint can be omitted. Pointer candidates still carry +their validation delta through the shared constraint evaluator; avoiding table +writes does not imply avoiding scans or validation. + +A replaced target fork can remain needed by another graph branch or by Lance +ancestry. RFC 0042's unique table-fork names let a later first-touch write create +a fresh ref from the adopted exact version without inspecting or reclaiming +the old ref. Explicit cleanup reclaims unused forks after its full protection +proof. The base manifest version in a fork name describes preparation and does +not replace this RFC's publication order. ### Worked example (the lower-number case, table `Knows`) @@ -359,23 +358,19 @@ Deny-list: none touched. Support boundary: unchanged. Lance data version to the manifest version: an incompatible manifest shape, since a v6 row key decoded by v7 would read a data version as a clock. `INTERNAL_MANIFEST_SCHEMA_VERSION` and `MIN_SUPPORTED_INTERNAL_SCHEMA_VERSION` - move together (`migrations.rs:70`, `:80`); v6 graphs are refused with the - export and rebuild message. The stamp moves to v7: the abandoned v7 to v19 - stamps of the MemWAL experiment never shipped - (`docs/dev/versioning.md:29`), `release_for_internal_schema_version` already - maps v6, and only the ceiling message and the docs rows - (`docs/dev/versioning.md`, `docs/user/operations/upgrade.md`, - `docs/dev/recovery.md`, `docs/dev/writes.md`, `docs/dev/testing.md`) change. - v6 is a released format, the - 0.9.x and 0.10.x line, so every existing deployment pays the rebuild described - under user behavior. + move together; v6/v7 graphs are refused by normal open. This clock was + introduced at v7; the current combined implementation uses v8 because RFC + 0042's native-ref retirement also changes storage interpretation. RFC 0064 + qualifies explicit offline v6 → v7 → v8 and v7 → v8 routes without widening + serving admission. The rejected MemWAL experiment's v7 to v19 stamps never + shipped and remain unsupported inputs (`docs/dev/versioning.md`). - **Converter.** `__manifest` is created with `enable_stable_row_ids: true` (`db/manifest/graph.rs:138`), so Lance's `_row_last_updated_at_version` system column holds, for every existing row, the manifest version that last - wrote it. A standalone one-shot converter could derive `manifest_version` - and re-key `object_id` from it without an in-place migration dispatcher - (the shape `migrations.rs` reserves for such a tool). Not required by this - RFC. + wrote it. RFC 0064's v6-to-v7 handler derives registration clocks from pinned + source provenance, retaining old snapshots with explicit v6 decoding. Its + owned pending intent, receipts and main-last activation preserve retry + ownership across the composed v8 route. - **Wire.** Unchanged: no response field is added or re-typed, and the 409 payload's `expected_published_dataset_version` and `actual_published_dataset_version` keep their meaning. @@ -396,7 +391,7 @@ Deny-list: none touched. Support boundary: unchanged. | Alternative | What it is | Why not | |---|---|---| | Do nothing | keep number ordering | the three defects in Motivation, one of them silent row loss | -| [PR #630](https://github.com/ModernRelay/omnigraph/pull/630)'s routing predicate | `adopt_requires_target_lineage` sends any adopt with `source ≤ target` number through the target-lineage delta writer, so the registered number always grows on the target | correct today, but the order is a rule every registering path must keep, not a property of the row; the equal-number case pays a data write for a pointer move; the equal-number pin gap in the precondition stays. Its one advantage over this RFC: the delta route never orphans the target's own fork ref, where the pointer switch does and charges the next first-touch write a `reclaim_orphaned_fork_and_refork`, a cost this RFC accepts | +| [PR #630](https://github.com/ModernRelay/omnigraph/pull/630)'s routing predicate | `adopt_requires_target_lineage` sends any adopt with `source ≤ target` number through the target-lineage delta writer, so the registered number always grows on the target | correct today, but the order is a rule every registering path must keep, not a property of the row; the equal-number case pays a data write for a pointer move; the equal-number pin gap in the precondition stays. the pointer route can leave an old fork for explicit cleanup, while RFC 0042's unique names keep reclamation off the next write | | Order by Lance's `_row_last_updated_at_version` on `__manifest`, keep the current key | stable row ids are on, so the value exists for every row | the row key still collides on equal numbers, so the handoff replacement stays. That replacement does order correctly: with the guard removed, `when_matched(UpdateAll)` stamps the replaced row with the publishing version and this order wins both merge cases (`lance_version_columns.rs:212`). It is rejected for its consequences instead: a replaced row disappears from the delta fold and from the row-immutability argument of Invariant 8, and every fold must project a Lance system column | | Re-key only: `object_id` on `(identity, table_version, table_branch)`, keep number ordering | removes the collision and the handoff arm | the lower case still loses: `(4, None)` at a lower number than `(5, f)` stays invisible; and the equal-number tie stays, since the fold's `>=` keeps whichever row the scan yields first | | Re-key on `(identity, table_version, table_branch)` and order the fold by `_row_last_updated_at_version` | the two rows above combined, each dismissed by the defect the other fixes | it fixes all three defects with no new column and no stamp, so it wins on compatibility and loses on authority: the logical fold would take its order from a Lance physical system column (the invariants' governing principle, nearest deny-list item "a logical precondition based on ... staged layout"), the column's value under `__manifest` in-place rewrite (`manifest.rs:1316`) is unverified, and inherited fragments carry Lance-owned stamps | @@ -432,11 +427,11 @@ side record". the HEAD assertion holds (a pointer switch writes no data). - Cross-version, the storage axis this stamp bump owes: `crates/omnigraph-cli/tests/crossversion_upgrade.rs` - `current_v7_refuses_and_rebuilds_genuine_v6_and_v6_refuses_v7` (skips unless + `current_v8_refuses_and_rebuilds_genuine_v6_and_v6_refuses_v8` (skips unless `OMNIGRAPH_V6_BIN` names a released 0.10.x binary): the new binary refuses a v6 graph naming the export binary, the old binary refuses the new stamp, and an export rebuilds with row, vector and blob fidelity; - `migrations.rs` keeps its stamp tests at v7. + `migrations.rs` checks the current v8 stamp, including refusal of v7. - GQ logic tests (`.gqt`, `crates/omnigraph-gqt/cases/`), all carried by this PR: `merge_adopt_lower_source_version_keeps_edges.gqt` and `merge_adopt_equal_source_version_collides.gqt` (red on `main` before this @@ -479,25 +474,27 @@ side record". 1. This RFC and its implementation ship in one PR, based on `main` with [PR #630](https://github.com/ModernRelay/omnigraph/pull/630) merged: the row key, - the `DatasetEntry` field, projection, stamp bump to v7, the + the `DatasetEntry` field, projection, v7 row-key semantics under the current + v8 stamp required by RFC 0042's native retirement, the batch-duplicate refusal, the collision guard and handoff arm removed, the pin's native ref, the read-side `manifest_internal` refusals, the RFC 0028 amendment, the `docs/dev/versioning.md` and `docs/user/operations/upgrade.md` rows, the release note, and the tests above. It also renames the local `manifest_version` in `repair.rs:198`, which holds a Lance data version and collides with the clock's name. A stamp - bump is a cutover, so the pieces cannot ship separately. The release that - carries it names this RFC as the sole reason for the rebuild unless another - stamp bump is planned for it. + bump is a cutover, so the pieces cannot ship separately. The release names + both this registration-clock change and RFC 0042's retirement metadata as + storage changes requiring explicit conversion or the export/rebuild fallback. 2. Ordering against [PR #630](https://github.com/ModernRelay/omnigraph/pull/630): that PR landed first (2026-09-08), so this PR removes `adopt_requires_target_lineage`, the routing it selects, its `docs/dev/merge.md` paragraphs and release-note entry, and re-asserts its `branching.rs`, `merge_fast_forward.rs` and `failpoints.rs` tests under the clock: an adopt at or below the target's number registers the source's - version as a pointer switch, or as a fork onto the target's ref when the - target does not own one, and stages no rows. The fork arm's next write is - a known gap (Decision log, 2026-09-08). + exact endpoint as a pointer switch on named targets and stages no rows. + A later first-touch write creates a unique fork under RFC 0042; the + historical fork/index gap remains recorded below and requires regression + evidence for the new route. 3. Optional: the one-shot converter, if a graph that cannot be rebuilt by export and load appears. @@ -506,9 +503,9 @@ On maintainer approval `status` moves to `accepted` in this PR; ## Unresolved questions -1. Converter in scope, or rebuild-only per the pre-release contract. Decider: - the release owners; forced by the first deployment that cannot rebuild by - export. Until then rebuild-only. +1. The original rebuild-only scope is amended by RFC 0064's qualified explicit + routes. Its remaining backend, cluster and acceptance gates govern further + expansion; export/rebuild remains the fallback for unsupported inputs. ## Decision log @@ -527,3 +524,8 @@ On maintainer approval `status` moves to `accepted` in this PR; is itself on a branch ref, so the arm stays and the lazy-target test stops after its reads. Follow-up: the merge's index handling after a fork from a branch ref. +- 2026-09-09: RFC 0042 now separates table-fork names from graph-branch native + refs and records ownership in `TableVersionMetadata.table_fork_owner`. + Named targets adopt source pointers even when they own a fork, and later + writes allocate fresh names. Cleanup handles unused forks. Publication order + remains the graph branch's manifest version. diff --git a/docs/rfcs/0064-explicit-storage-upgrades.md b/docs/rfcs/0064-explicit-storage-upgrades.md index ffacd3e2..d6eccc87 100644 --- a/docs/rfcs/0064-explicit-storage-upgrades.md +++ b/docs/rfcs/0064-explicit-storage-upgrades.md @@ -7,7 +7,7 @@ implementation: in-progress authors: - Azim Afroozeh created: 2026-09-09 -updated: 2026-09-09 +updated: 2026-09-10 discussion: null supersedes: [] superseded_by: [] @@ -26,18 +26,19 @@ blocked_on: Extend `omnigraph upgrade` to select developer-written ***migration handlers***, code for named conversions with declared inputs, outputs, prerequisites, checks, effects, validation, and recovery. -Start with internal v6 to v7 registration metadata ([RFC 0062](0062-manifest-version-clock.md)); +Compose internal v6 to v7 registration metadata ([RFC 0062](0062-manifest-version-clock.md)) +with metadata-only v7 to v8 retirement admission ([RFC 0042](0042-incarnation-suffixed-branch-refs.md)); reuse table data only where its meaning and references remain valid. Later handlers can cover [RFC 0040](0040-system-column-namespace.md)'s system -columns and settled fork ownership. Arbitrary conversions are unsupported; -the default is fast, in-place migration that appends metadata and reuses table +columns and separately qualified historical fork-ownership conversion. Arbitrary +conversions are unsupported; the default is fast, in-place migration that appends metadata and reuses table data. Publication and recovery still require proof; reclamation runs later. ## Motivation -Today's [versioning policy](../dev/versioning.md) refuses incompatible graphs -and requires export/import, preserving exported entities but restarting commit -history, snapshots, and shared branch ancestry. RFC 0062 identifies a metadata +Normal open under the [versioning policy](../dev/versioning.md) refuses +incompatible graphs. Export/import preserves exported entities but restarts +commit history, snapshots, and shared branch ancestry. RFC 0062 identifies a metadata converter using `_row_last_updated_at_version`; RFC 0040 specifies a recoverable explicit upgrade. Extend that entry point to cover both without conflating their protocols. @@ -52,14 +53,16 @@ Proposed migration behavior and options for `omnigraph upgrade`: ```text omnigraph upgrade -omnigraph upgrade --check --to-format 7 --json -omnigraph upgrade --to-format 7 --json +omnigraph upgrade --check --to-format 8 --json +omnigraph upgrade --to-format 8 --json ``` The binary selects registered handlers from the stored format/capabilities to one declared default target, independent of its serving range and release -number. `--to-format` overrides the target, never names a handler; v7 can stop -before system-column conversion. The default does not adapt to the graph. +number. The current default is v8. `--to-format` overrides the target, never +names a handler: explicit v7 stops after registration conversion for a +v7-compatible executable, while normal open in the current binary still requires +v8. The default does not adapt to the graph. Missing steps, cycles, ambiguous routes, and unimplemented handlers block execution before effects. Numeric adjacency proves no route: graph format, schema IR, and recovery format are separate compatibility axes. @@ -73,7 +76,10 @@ schema IR, and recovery format are separate compatibility axes. An online check is advisory: it proves neither converted-output correctness nor future availability and never authorizes serving with the target binary. -`--check` may report recovery actions but cannot perform them. +`--check` may report recovery actions but cannot perform them. A v6 → v8 check +cannot inspect the future converted v7 output: `work.deferred_checks` names those +output-dependent checks, which execution must run before the v7 → v8 handler's +effects. A passing check does not imply those deferred checks have passed. | Outcome | Meaning | Exit | |---|---|---| @@ -107,9 +113,10 @@ policy, or runtime ownership. Refuse that route until implemented and tested. Use compiled concrete handler functions, sharing admission, planning, reporting, and results. Developers supply rules and tests; the binary selects rather than -invents conversions. A v6 graph targeting v7 selects its registered v6 to v7 -handler; longer routes require every declared prerequisite. Compatible releases -need no new handler. No plugins, third-party scripts, schema-diff language, or +invents conversions. A v6 graph targeting v8 selects protocol 1 (v6 to v7), +then protocol 2 (v7 to v8). A v7 graph selects only protocol 2. Explicit target +v7 selects only the first handler when required; longer routes require every +declared prerequisite. Compatible releases need no new handler. No plugins, third-party scripts, schema-diff language, or generic migration ledger/scheduler. Share recovery only when effect identities and publication rules match. Durable graph state and owned recovery establish completion; progress reports and early-fence target stamps cannot establish it alone. @@ -247,6 +254,30 @@ backup is established; preflight and results enumerate exclusions. Identical descriptors do not prove byte preservation. Required shared Lance files cannot use this exclusion. +### v7 to v8 and route composition + +The v7-to-v8 handler preserves registration rows, table pointers, commit IDs and +historical locators. It changes only manifest configuration through the sealed +publication gateway; no table data or registration keys are rewritten. Retained +v7 snapshots keep their original interpretation. This handler enables the v8 +retirement contract without retiring a branch or inferring legacy fork ownership. + +Protocol 2 retains all-branch admission, an owned pending intent on main, +per-branch publication receipts and main-last activation. Every branch must +validate before main clears the pending intent. The current binary refuses v7 +before conversion and any pending state during conversion. Current v8 no-op +admission validates retirement markers and excludes valid retired refs from +logical branch enumeration while preserving their physical ancestry. Source +v6/v7 admission refuses any reserved retirement metadata, including a marker +that would be valid under v8; conversion cannot legitimize ambiguous source state. + +Protocol 1 remains exactly the v6-to-v7 protocol above. Existing pending attempts +retain their source, target, attempt identity and receipts. A request for v8 +finishes that owned v7 target before starting protocol 2; it never reinterprets +the earlier pending intent as a v8 attempt. A later-step failure does not undo +v7 activation. Explicit target v7 stops there, and cannot downgrade a pending +v7-to-v8 attempt. Normal serving still requires v8 after the complete route. + ### Later handlers RFC 0040 owns main-only admission, preflight, `SchemaApply` recovery, and ordered @@ -257,7 +288,8 @@ stamp fence precludes a universal stamp-last rule. Fork conversion requires an accepted ownership representation and durable proof of existing ownership. Never infer it from plausible names, recreate missing incarnations, or assign current ownership to historical borrowers. Ambiguity -refuses. Format number and ordering remain unassigned, not automatically v7 to v8. +refuses. Rewriting historical fork ownership remains a separate conversion; +the registered v7-to-v8 handler introduces retirement interpretation only. ## Invariants @@ -283,9 +315,10 @@ required dependencies and declared external-byte exclusions. Retained old object in the same root do not by themselves provide rollback after activation or fencing. Plan explicitly for post-cutover writes absent from that backup. Later-handler failure never automatically undoes completed handlers. -Acceptance amends rebuild-only policy for qualified routes while retaining the -fallback. Coordinate policy/RFC 0062 and RFC 0040 command wording when implemented; -current user docs must not advertise this unimplemented draft. +Qualified registered routes amend rebuild-only policy while retaining the +fallback. User guidance and the support matrix name only implemented, qualified +routes; unqualified backends, cluster entry points and later handlers remain +excluded while this RFC's acceptance gates are open. ## Alternatives @@ -390,8 +423,8 @@ enforcement itself. recovery, safe deferred cleanup, and genuine v6-binary compatibility. 2. Qualify the production handler against the full matrix on every advertised backend; prototype acceptance does not replace shipping evidence. -3. Ship check and qualified v6 to v7 execution with upgrade/versioning guidance, - required compatibility CI, and genuine-binary evidence. Check may ship earlier +3. Ship check and qualified v6 → v7 → v8 and v7 → v8 execution with + upgrade/versioning guidance, required compatibility CI, and genuine-binary evidence. Check may ship earlier only if execution unavailability is explicit. 4. Integrate RFC 0040 and settled fork handlers under their own gates; refuse unsupported chains before effects. diff --git a/docs/user/branching/index.md b/docs/user/branching/index.md index 7d4fc52a..441be9fc 100644 --- a/docs/user/branching/index.md +++ b/docs/user/branching/index.md @@ -62,19 +62,17 @@ prevent `omnigraph cleanup` from reclaiming old data. Branch names may contain `/`, but live names must not be path prefixes of one another. For example, `review` and `review/alice` cannot coexist. `main` is -reserved. A branch with descendants must be deleted leaf-first. A path segment +reserved. A parent branch can be deleted while descendants remain. A path segment may not end in `.` followed by 26 upper-case letters and digits: that shape is reserved for OmniGraph's internal per-life branch identity. Ordinary dotted names such as `release.1.2` are fine. Deleting a branch and creating another with the same name yields a fresh -branch: it shares no storage with the deleted one, and readers that captured -the deleted branch fail with a typed error rather than seeing the new data. -`branch delete` returns once the branch is logically deleted; its per-dataset -storage is reclaimed in the background (bounded by a 600-second watchdog), and -branch operations that would conflict with that reclaim wait for it. The CLI -and server join in-flight reclaims before exit, and `omnigraph cleanup` -remains the backstop for anything abandoned. +branch lifetime. Readers that captured the deleted branch fail with a typed +error rather than seeing the replacement. Deletion retires the logical name +and leaves native manifest history and table storage for explicit `omnigraph cleanup`; it starts no background table-reclaim work and +does not wait for table-fork reclamation. Cleanup preserves data still needed by +other branches and their underlying histories. Branch-control operations are safe across handles in one writer process. Do not run branch create/delete control concurrently from separate writer processes diff --git a/docs/user/branching/merge.md b/docs/user/branching/merge.md index 769a687d..9f8546d3 100644 --- a/docs/user/branching/merge.md +++ b/docs/user/branching/merge.md @@ -40,24 +40,17 @@ it with a `table version … already exists` error. - **Merged**: both branches changed, so OmniGraph performs a three-way, entity-level merge and creates a commit with two parents. -Merging main into a branch points each table that only main changed since the -branch last shared it at main's history and leaves the branch's own table -history in place; a table both sides changed is merged onto the branch's own -history as usual. A child that inherited an -older table snapshot from that branch keeps reading its original values -through the merge, later writes, and cleanup. - -While a child still uses the branch's former table history, a write on the -branch to that table reports `detached native lineage`. Create a replacement -branch from the affected branch and continue writes there. Existing children -retain their original snapshots; retrying the same write on the affected -branch cannot repair its detached history, and merging the replacement back -into the affected branch reports the same error for as long as a child still -uses that history. The affected branch accepts writes and merges to that -table again once every such child has been deleted. A child that has written -the table itself no longer uses the former history directly, but its own -history descends from it, so until that child is deleted the affected -branch's write reports `dependent child branches` instead. +When only the source changed a table since the merge base, a merge into a +named branch can adopt that exact table snapshot without copying its rows. +This also works when the target already has its own table history. Tables +changed on both sides still use the normal three-way merge and constraint +checks. + +The target can continue writing after adoption. Other live branches keep their +current table snapshots through those target writes and cleanup; +creating a replacement branch is unnecessary. Cleanup retains the underlying +histories they still need. Avoiding a table copy does not eliminate the reads +needed to validate a merge. The source branch is unchanged by the merge. Use `--delete-branch` for the normal review-branch lifecycle: @@ -144,12 +137,9 @@ default, used for validation; it costs both paths). A merge that succeeds produces the same result in every mode; only cost differs. An unrecognized value logs a warning and behaves as `off`. -A merge into a branch whose former table history no other branch uses drops -that history before it starts and forks afresh, the same as the branch's own -next write would. -If merge reports that a pending operation still claims the target ref or that -its liveness could not be verified, it has refused before creating recovery -state; retry. +Later writes create fresh table storage when needed and leave unused former +storage for explicit cleanup. They do not reclaim a previous table history as +part of preparing the write or merge. ## After a large merge diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 46d1521e..c61ec574 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -127,16 +127,20 @@ when it changed unrelated data. A mismatch has no effect and exits with code ## Storage upgrade ```bash -omnigraph upgrade ./graph.omni --check --to-format 7 --json -omnigraph upgrade ./graph.omni --to-format 7 --json +omnigraph upgrade ./graph.omni --check --to-format 8 --json +omnigraph upgrade ./graph.omni --to-format 8 --json ``` `--store` is an alternative to the positional storage URI. Target format defaults -to 7. `--check` performs read-only preflight; execution requires stopped writers, -stopped maintenance and a verified whole-root backup. A failed check, refusal or +to 8: qualified v6 inputs run v6 → v7 → v8, and v7 inputs run v7 → v8. +Explicit target 7 remains available, but the current binary refuses normal open +of v7. `--check` performs read-only preflight and reports output-dependent checks +in `work.deferred_checks`; execution validates those before the affected handler +has effects. Execution requires stopped writers, stopped maintenance and a +verified whole-root backup. A failed check, refusal or required recovery exits 1. JSON reports the route, formats, findings, durable boundary, recovery action and work categories. Server and cluster addressing -are refused. See [storage migration](../operations/upgrade.md#explicit-v6-to-v7-storage-migration). +are refused. See [storage migration](../operations/upgrade.md#explicit-storage-migration). ## Load modes diff --git a/docs/user/operations/maintenance.md b/docs/user/operations/maintenance.md index 2c7bfc9c..e59a6fe6 100644 --- a/docs/user/operations/maintenance.md +++ b/docs/user/operations/maintenance.md @@ -5,7 +5,7 @@ OmniGraph provides four direct-storage maintenance commands: - `optimize` compacts data and reconciles declared indexes. - `rebuild-full-text-indexes` replaces full-text indexes on one branch. - `repair` classifies storage drift and can publish an approved repair. -- `cleanup` permanently removes old versions. +- `cleanup` permanently removes eligible old versions and unused table forks. They do not run through the HTTP server. Address a standalone graph directly, or select a graph from a cluster root: @@ -28,7 +28,8 @@ omnigraph optimize ./graph.omni --json Optimize rewrites small fragments into fewer larger fragments, refreshes scalar and vector coverage, and builds missing declared indexes that are ready to build. It does not -delete old versions, so snapshots and retained history remain available. +delete old versions or collect unused table forks. Use `cleanup` for storage +reclamation. Optimize also persists the traversal-adjacency artifact (`__graph_index/csr-current.bin`), which cold traversal builds load instead of @@ -113,8 +114,11 @@ or backup. ## Cleanup -Cleanup permanently removes old versions from the backing datasets for node and -edge types, plus data reachable only through those versions. Without +Cleanup collects unused table forks and permanently removes eligible old +versions from node and edge datasets, plus data reachable only through those +versions. Branch deletion and later writes leave reclamation to this command. +Forks still needed by a live branch or its underlying history remain protected, +even if their original branch no longer uses them. Without `--confirm`, the CLI only echoes the requested retention policy and exits before opening the graph; it does not enumerate candidate versions: @@ -132,13 +136,17 @@ At least one retention option is required: | Option | Meaning | |---|---| -| `--keep N` | Request retention of the newest `N` versions per node or edge type | -| `--older-than DURATION` | Remove only versions older than the duration | +| `--keep N` | Request retention of the newest `N` versions per retained node or edge dataset | +| `--older-than DURATION` | Remove only older versions; defer unused-fork collection while any data or branch-reference object is newer than the cutoff | When both are present, a version must be outside both retention windows before it can be removed. Live branches and other storage references may keep -additional versions. `--keep 10` is a conservative starting point; choose a -policy that matches your rollback and audit needs. +additional versions. The count applies to dataset versions, not graph commits, +and does not retain unused forks indefinitely. With `--keep` alone, an unused +fork can be collected immediately. An explicit `--older-than` also protects the +entire fork until every data and branch-reference object is older than the +cutoff. Deleting a branch starts a fresh grace period for its retained history, +even when that history is old. Choose a policy that matches your rollback and audit needs. For `s3://` and `az://` targets, destructive execution also requires an interactive confirmation or `--yes`. Non-interactive and JSON runs refuse diff --git a/docs/user/operations/upgrade.md b/docs/user/operations/upgrade.md index db3d7a4f..436642ed 100644 --- a/docs/user/operations/upgrade.md +++ b/docs/user/operations/upgrade.md @@ -5,13 +5,16 @@ for a registered route, or export/import with a source-compatible binary when no route exists. Storage formats, release versions and full-text index formats are separate; check the [release notes](../../releases/) before upgrading. -## Explicit v6 to v7 storage migration +## Explicit storage migration -`omnigraph upgrade` converts standalone v6 graphs created by the 0.9.x/0.10.x -release lines to v7 in the same location. It appends manifest metadata and reuses -table data. It preserves branch ancestry, IDs, property values, schema identity, -retained commit IDs and numeric snapshots. Historical v6 snapshots keep their -original metadata and use an explicit legacy decoder after root admission. +`omnigraph upgrade` defaults to storage format v8 in the same location. +Qualified standalone v6 graphs from the 0.9.x/0.10.x release lines run v6 → v7 +registration conversion followed by metadata-only v7 → v8 conversion. Qualified +v7 development graphs run only the final step. Both reuse table data and preserve +branch ancestry, IDs, property values, schema identity, retained commit IDs and +numeric snapshots. Historical v6 snapshots keep their original metadata and use +an explicit legacy decoder after root admission; historical v7 snapshots keep +their registration-clock interpretation. 1. Stop every server, embedded writer, maintenance process and cluster apply that could touch the graph or its shared dependencies. A process-local lock @@ -21,12 +24,15 @@ original metadata and use an explicit legacy decoder after root admission. 3. Run preflight with the new binary: ```bash - omnigraph upgrade ./graph.omni --check --to-format 7 --json + omnigraph upgrade ./graph.omni --check --to-format 8 --json ``` 4. Inspect `outcome`, `findings`, `route` and `work`. A passing check is advisory; - execution repeats validation. Resolve source recovery with the compatible - source executable before retrying. Shared Lance files outside the root refuse. + execution repeats validation. For v6 → v8, `work.deferred_checks` identifies + checks requiring the intermediate v7 output. Execution validates these before + the v7 → v8 handler has effects; a passing check does not pre-approve them. + Resolve source recovery with the compatible source executable before retrying. + Shared Lance files outside the root refuse. `work.external_blob_exclusions` lists external URI bytes whose immutability and backup are outside the migration guarantee; their descriptors are retained. `work.historical_blob_identity_limits` lists pre-0.10 Blob fields without @@ -38,7 +44,7 @@ original metadata and use an explicit legacy decoder after root admission. 5. Execute while the graph remains offline: ```bash - omnigraph upgrade ./graph.omni --to-format 7 --json + omnigraph upgrade ./graph.omni --to-format 8 --json ``` 6. Verify reads on every branch and retained snapshot, then start only the new @@ -46,17 +52,28 @@ original metadata and use an explicit legacy decoder after root admission. with the old executable; old bytes remaining in the upgraded root do not make downgrading safe. Post-upgrade writes are absent from that backup. -`--to-format` defaults to the binary's declared target, currently 7. Unsupported -sources and targets refuse; there is no automatic data-moving fallback. Both -check and execution return zero only for success (`check_passed`, `completed` -or `already_current`). Repeated successful execution is a no-write no-op. - -After the early fence, ordinary opens refuse until every branch is converted -and validated. If interrupted, retain the backup and rerun the same command -with `--to-format 7`, without `--check`, using this upgrade-capable executable. -The report identifies the last durable boundary and required recovery action. -Unknown ownership or foreign branch movement requires investigation; never -delete the pending marker to force serving or point the source executable at it. +`--to-format` defaults to 8. Explicit `--to-format 7` stops at v7 for a +v7-compatible executable; the current binary accepts only v8 for normal open +and will refuse that intermediate result. Unsupported sources and targets +refuse; there is no automatic data-moving fallback. Both check and execution +return zero only for success (`check_passed`, `completed` or `already_current`). +Repeated successful execution is a no-write no-op after admission checks. + +After each handler's early fence, ordinary opens refuse until every branch is +converted and validated and main activates that handler's target. If interrupted, +retain the backup and rerun the same requested target without `--check`, using +this upgrade-capable executable. An earlier pending v6 → v7 attempt keeps its +exact original protocol, target and attempt identity; requesting v8 first +finishes that attempt, then runs v7 → v8. Requesting v7 cannot downgrade or +resume a pending v7 → v8 attempt. The report identifies the last durable boundary +and required recovery action. Unknown ownership or foreign branch movement +requires investigation; never delete the pending marker to force serving or +point the source executable at it. + +Source v6/v7 graphs containing reserved native-ref retirement metadata refuse +conversion. On a current v8 graph, admission validates that metadata and excludes +valid retired refs from logical branch enumeration while retaining their physical +ancestry. Upgrade neither retires branches nor reclaims their storage. Branch naming must also be unambiguous. A native name ending in a ULID-shaped suffix could be either a v0.9 logical name or a newer branch incarnation. The @@ -67,7 +84,7 @@ incarnation-shaped inner path segments also refuse. Resolve the branch naming with the source executable or use the export/rebuild fallback; do not rename native Lance refs or edit their metadata manually. -This initial handler bounds each retained manifest to 1,000,000 rows and 64 MiB +Upgrade admission bounds each retained manifest to 1,000,000 rows and 64 MiB of decoded batch metadata, 1,024 native branches and 100,000 retained versions per branch. Version references are counted before historical manifests are loaded. Exceeding a bound refuses before conversion. Payload bytes copied @@ -101,9 +118,9 @@ upgrade is proven. See the [v0.10 compatibility notes](../../releases/v0.10.0.md ## Full-text index upgrade -The Lance 11 upgrade keeps graph storage format v6, entities, branches, and -history. It changes the English stemmer used by full-text search. Old indexes -cannot safely be searched by the new analyzer, so OmniGraph explicitly refuses +The v0.9-to-v0.10 Lance 11 transition keeps graph storage format v6, entities, +branches, and history. It changes the English stemmer used by full-text search. +Old indexes cannot safely be searched by the new analyzer, so OmniGraph explicitly refuses full-text queries until the selected indexes have been rebuilt. 1. Stop application readers and writers. Using the old CLI, inventory the live @@ -214,7 +231,8 @@ mapping is: | v4 | latest 0.8.x | | v5 | the exact unreleased development build that wrote it | | v6 | latest 0.10.x (the refusal names 0.9.x or 0.10.x) | -| v7 | current 0.11.x line; entity export/import normally not required within this generation | +| v7 | the exact unreleased development build that wrote it | +| v8 | current 0.11.x line; entity export/import normally not required within this generation | If the graph's generation is newer than the binary, upgrade the binary rather than rebuilding with it. diff --git a/scripts/bench-branch-age.py b/scripts/bench-branch-age.py index 024f8391..bce093c5 100644 --- a/scripts/bench-branch-age.py +++ b/scripts/bench-branch-age.py @@ -93,8 +93,8 @@ def admit(records, point, runs, identity, binary_hash): require(metrics.get(f"setup_{label}_requested") == point["params"][parameter] == metrics.get(f"setup_{label}_applied"), "Fixture aging was not applied") require(metrics.get("setup_age_content_verified") is True, "Missing age content proof") - require(metrics.get("setup_retired_native_refs_reclaimed") == point["params"]["retired_branches"], - "Retired fixture refs were not reclaimed") + require(metrics.get("setup_retired_native_refs_retained") == point["params"]["retired_branches"], + "Retired fixture refs were not retained until cleanup") require(metrics["setup_main_history_after_age"] - metrics["setup_main_history_before_age"] == point["params"]["history_commits"], "Actual reachable history did not match") require(isinstance(metrics.get("operation_io_manifest_reads"), int), "Missing manifest I/O evidence") diff --git a/scripts/check-docs.py b/scripts/check-docs.py index 6f60a49b..86c5180d 100644 --- a/scripts/check-docs.py +++ b/scripts/check-docs.py @@ -73,7 +73,7 @@ def tracked_markdown() -> list[Path]: { ROOT / line for line in result.stdout.splitlines() - if line and (ROOT / line).is_file() + if line and (ROOT / line).is_file() and not line.startswith("vendor/lance/") } )