Implement RFC-022 unified graph write protocol (#343)

* Implement unified graph write protocol

* Preserve recovery error wire compatibility
This commit is contained in:
Andrew Altshuler 2026-07-11 14:02:54 +03:00 committed by GitHub
parent 0c8d769501
commit f758ff0d17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 13393 additions and 2050 deletions

View file

@ -577,6 +577,21 @@ pub struct ManifestConflictOutput {
pub actual: u64,
}
/// Structured authority mismatch for an RFC-022 prepared write. Values are
/// strings because members include optional graph commit ids and future
/// authority tokens, not only numeric table versions.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ReadSetConflictOutput {
pub member: String,
pub expected: Option<String>,
pub actual: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RecoveryRequiredOutput {
pub operation_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ErrorOutput {
pub error: String,
@ -590,6 +605,13 @@ pub struct ErrorOutput {
/// manifest is now at `actual`. Refresh and retry.
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest_conflict: Option<ManifestConflictOutput>,
/// Set when a prepared write's logical authority changed before effects.
#[serde(skip_serializing_if = "Option::is_none")]
pub read_set_conflict: Option<ReadSetConflictOutput>,
/// Set when an overlapping durable recovery intent must be resolved before
/// retry. Its table effects may or may not have started.
#[serde(skip_serializing_if = "Option::is_none")]
pub recovery_required: Option<RecoveryRequiredOutput>,
}
pub fn snapshot_payload(

View file

@ -788,8 +788,9 @@ pub(crate) async fn run_query(
(status = 400, description = "Bad request", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 409, description = "Merge conflict", body = ErrorOutput),
(status = 409, description = "Write-authority conflict", body = ErrorOutput),
(status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput),
(status = 503, description = "An overlapping durable recovery intent must be resolved before retry", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
@ -837,8 +838,9 @@ pub(crate) async fn server_change(
(status = 400, description = "Bad request", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 409, description = "Merge conflict", body = ErrorOutput),
(status = 409, description = "Write-authority conflict", body = ErrorOutput),
(status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput),
(status = 503, description = "An overlapping durable recovery intent must be resolved before retry", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
@ -847,7 +849,8 @@ pub(crate) async fn server_change(
/// Writes to the named `branch` (defaults to `main`). Mutations are atomic
/// per call and produce a new commit. Returns counts of nodes and edges
/// affected. **Destructive**: on success the branch is updated; rejected
/// mutations may still acquire locks briefly. Returns 409 on merge conflict.
/// mutations may still acquire locks briefly. Returns 409 when the prepared
/// write authority changes before effects.
///
/// Pairs with `POST /query` (read-only). The legacy `POST /change` route
/// has identical semantics and is kept as a deprecated alias.
@ -904,9 +907,10 @@ pub(crate) fn parse_optional_invoke_body(
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden (the inner `change` gate for a stored mutation)", body = ErrorOutput),
(status = 404, description = "Unknown stored query, or `invoke_query` denied — indistinguishable to a caller without the grant", body = ErrorOutput),
(status = 409, description = "Merge conflict", body = ErrorOutput),
(status = 409, description = "Stored mutation write-authority conflict", body = ErrorOutput),
(status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput),
(status = 500, description = "Policy evaluation error (a denial is reported as 404, not 500)", body = ErrorOutput),
(status = 503, description = "A stored mutation is blocked by a durable recovery intent", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
@ -1203,25 +1207,11 @@ pub(crate) async fn server_schema_apply(
.await
.map_err(ApiError::from_omni)?
};
// Prompt index convergence (iss-848): schema apply records `@index` intent
// but defers the physical build. On a long-lived server, materialize it
// promptly rather than waiting for the next `optimize` cron — spawned
// detached so it never blocks or fails the apply response. Best-effort: a
// failure is logged and the index still converges on the next optimize.
// The CLI is one-shot, so it has no equivalent; its convergence path is the
// operator's optimize cadence.
if result.applied {
let engine = Arc::clone(&handle.engine);
tokio::spawn(async move {
if let Err(err) = engine.ensure_indices().await {
tracing::warn!(
target: "omnigraph::server",
error = %err,
"post-apply ensure_indices failed; indexes will converge on the next optimize",
);
}
});
}
// Physical indexes are derived state. Schema apply records intent only;
// explicit `ensure_indices` / `optimize` maintenance owns convergence on
// every surface, including a long-lived server. Keeping the handler free
// of detached physical writes also makes a successful response describe
// the complete effect envelope of this request.
Ok(Json(schema_apply_output(handle.uri.as_str(), result)))
}
@ -1313,7 +1303,9 @@ async fn run_ingest(
(status = 400, description = "Bad request", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 409, description = "Prepared load authority changed before effects", body = ErrorOutput),
(status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput),
(status = 503, description = "An overlapping durable recovery intent must be resolved before retry", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
@ -1357,7 +1349,9 @@ pub(crate) async fn server_load(
(status = 400, description = "Bad request", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 409, description = "Prepared load authority changed before effects", body = ErrorOutput),
(status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput),
(status = 503, description = "An overlapping durable recovery intent must be resolved before retry", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
@ -1437,6 +1431,7 @@ pub(crate) async fn server_branch_list(
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 409, description = "Branch already exists", body = ErrorOutput),
(status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput),
(status = 503, description = "An overlapping durable recovery intent must be resolved before retry", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
@ -1518,6 +1513,7 @@ pub(crate) struct BranchPath {
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 404, description = "Branch not found", body = ErrorOutput),
(status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput),
(status = 503, description = "An overlapping durable recovery intent must be resolved before retry", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
@ -1579,6 +1575,7 @@ pub(crate) async fn server_branch_delete(
(status = 403, description = "Forbidden", body = ErrorOutput),
(status = 409, description = "Merge conflict", body = ErrorOutput),
(status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput),
(status = 503, description = "An overlapping durable recovery intent must be resolved before retry", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]

View file

@ -286,10 +286,12 @@ impl Write for ExportStreamWriter {
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
code: ErrorCode,
code: Option<ErrorCode>,
message: String,
merge_conflicts: Vec<api::MergeConflictOutput>,
manifest_conflict: Option<api::ManifestConflictOutput>,
read_set_conflict: Option<api::ReadSetConflictOutput>,
recovery_required: Option<api::RecoveryRequiredOutput>,
}
impl AppState {
@ -616,40 +618,48 @@ impl ApiError {
pub fn unauthorized(message: impl Into<String>) -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
code: ErrorCode::Unauthorized,
code: Some(ErrorCode::Unauthorized),
message: message.into(),
merge_conflicts: Vec::new(),
manifest_conflict: None,
read_set_conflict: None,
recovery_required: None,
}
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self {
status: StatusCode::FORBIDDEN,
code: ErrorCode::Forbidden,
code: Some(ErrorCode::Forbidden),
message: message.into(),
merge_conflicts: Vec::new(),
manifest_conflict: None,
read_set_conflict: None,
recovery_required: None,
}
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
code: ErrorCode::BadRequest,
code: Some(ErrorCode::BadRequest),
message: message.into(),
merge_conflicts: Vec::new(),
manifest_conflict: None,
read_set_conflict: None,
recovery_required: None,
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: ErrorCode::NotFound,
code: Some(ErrorCode::NotFound),
message: message.into(),
merge_conflicts: Vec::new(),
manifest_conflict: None,
read_set_conflict: None,
recovery_required: None,
}
}
@ -660,30 +670,36 @@ impl ApiError {
pub fn method_not_allowed(message: impl Into<String>) -> Self {
Self {
status: StatusCode::METHOD_NOT_ALLOWED,
code: ErrorCode::MethodNotAllowed,
code: Some(ErrorCode::MethodNotAllowed),
message: message.into(),
merge_conflicts: Vec::new(),
manifest_conflict: None,
read_set_conflict: None,
recovery_required: None,
}
}
pub fn conflict(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
code: ErrorCode::Conflict,
code: Some(ErrorCode::Conflict),
message: message.into(),
merge_conflicts: Vec::new(),
manifest_conflict: None,
read_set_conflict: None,
recovery_required: None,
}
}
pub fn internal(message: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
code: ErrorCode::Internal,
code: Some(ErrorCode::Internal),
message: message.into(),
merge_conflicts: Vec::new(),
manifest_conflict: None,
read_set_conflict: None,
recovery_required: None,
}
}
@ -694,10 +710,12 @@ impl ApiError {
pub fn too_many_requests(message: impl Into<String>) -> Self {
Self {
status: StatusCode::TOO_MANY_REQUESTS,
code: ErrorCode::TooManyRequests,
code: Some(ErrorCode::TooManyRequests),
message: message.into(),
merge_conflicts: Vec::new(),
manifest_conflict: None,
read_set_conflict: None,
recovery_required: None,
}
}
@ -715,20 +733,51 @@ impl ApiError {
fn merge_conflict(conflicts: Vec<api::MergeConflictOutput>) -> Self {
Self {
status: StatusCode::CONFLICT,
code: ErrorCode::Conflict,
code: Some(ErrorCode::Conflict),
message: summarize_merge_conflicts(&conflicts),
merge_conflicts: conflicts,
manifest_conflict: None,
read_set_conflict: None,
recovery_required: None,
}
}
fn manifest_version_conflict(message: String, details: api::ManifestConflictOutput) -> Self {
Self {
status: StatusCode::CONFLICT,
code: ErrorCode::Conflict,
code: Some(ErrorCode::Conflict),
message,
merge_conflicts: Vec::new(),
manifest_conflict: Some(details),
read_set_conflict: None,
recovery_required: None,
}
}
fn read_set_conflict(message: String, details: api::ReadSetConflictOutput) -> Self {
Self {
status: StatusCode::CONFLICT,
code: Some(ErrorCode::Conflict),
message,
merge_conflicts: Vec::new(),
manifest_conflict: None,
read_set_conflict: Some(details),
recovery_required: None,
}
}
fn recovery_required(message: String, operation_id: String) -> Self {
Self {
status: StatusCode::SERVICE_UNAVAILABLE,
// `ErrorCode` is a closed rolling wire contract. The additive
// `recovery_required` field carries the new meaning while older
// clients continue to deserialize the otherwise familiar body.
code: None,
message,
merge_conflicts: Vec::new(),
manifest_conflict: None,
read_set_conflict: None,
recovery_required: Some(api::RecoveryRequiredOutput { operation_id }),
}
}
@ -752,6 +801,18 @@ impl ApiError {
actual,
},
),
Some(ManifestConflictDetails::ReadSetChanged {
member,
expected,
actual,
}) => Self::read_set_conflict(
err.message,
api::ReadSetConflictOutput {
member,
expected,
actual,
},
),
_ => Self::conflict(err.message),
},
ManifestErrorKind::Internal => Self::internal(err.message),
@ -762,6 +823,13 @@ impl ApiError {
.map(api::MergeConflictOutput::from)
.collect(),
),
OmniError::RecoveryRequired {
operation_id,
reason,
} => Self::recovery_required(
format!("recovery required for operation {operation_id}: {reason}"),
operation_id,
),
OmniError::Lance(message) => Self::internal(format!("storage: {message}")),
OmniError::Io(err) => Self::internal(format!("io: {err}")),
// Engine-layer policy enforcement (MR-722). All denials and
@ -815,7 +883,7 @@ const RETRY_AFTER_SECONDS: &str = "60";
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let mut headers = axum::http::HeaderMap::new();
if matches!(self.code, ErrorCode::TooManyRequests) {
if matches!(self.code, Some(ErrorCode::TooManyRequests)) {
headers.insert(
axum::http::header::RETRY_AFTER,
axum::http::HeaderValue::from_static(RETRY_AFTER_SECONDS),
@ -826,15 +894,42 @@ impl IntoResponse for ApiError {
headers,
Json(ErrorOutput {
error: self.message,
code: Some(self.code),
code: self.code,
merge_conflicts: self.merge_conflicts,
manifest_conflict: self.manifest_conflict,
read_set_conflict: self.read_set_conflict,
recovery_required: self.recovery_required,
}),
)
.into_response()
}
}
#[cfg(test)]
mod api_error_tests {
use super::*;
#[tokio::test]
async fn recovery_required_503_omits_closed_error_code() {
let response = ApiError::from_omni(OmniError::RecoveryRequired {
operation_id: "01JTESTRECOVERY".to_string(),
reason: "pending recovery intent".to_string(),
})
.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let error: ErrorOutput = serde_json::from_slice(&body).unwrap();
assert_eq!(error.code, None);
assert_eq!(
error.recovery_required.unwrap().operation_id,
"01JTESTRECOVERY"
);
}
}
pub fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();

View file

@ -1099,18 +1099,15 @@ query vector_search_string($q: String) {
}
#[tokio::test(flavor = "multi_thread")]
async fn change_conflict_returns_manifest_conflict_409() {
// A write that races with another writer surfaces as HTTP 409 with
// a structured `manifest_conflict` body — `table_key`, `expected`,
// and `actual` — so clients can detect-and-retry without parsing
// the message.
async fn change_long_lived_handle_refreshes_before_preparing_write() {
// A handle that merely predates another committed write is not stale
// authority: open_write_txn probes the manifest incarnation and prepares
// from the fresh head. ReadSetChanged is reserved for movement *during* an
// already-prepared attempt (covered by the concurrent test below).
let temp = init_loaded_graph().await;
let graph = graph_path(temp.path());
// Build the server first so its handle pins the pre-mutation manifest
// version. Then advance the manifest from outside the server. The
// server's next /change call will capture stale `expected_versions`
// (from its still-pinned snapshot) and the publisher's CAS rejects.
// Build the server first, then advance the graph through another handle.
let state = AppState::open(graph.to_string_lossy().to_string())
.await
.unwrap();
@ -1154,34 +1151,17 @@ async fn change_conflict_returns_manifest_conflict_409() {
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
let error: ErrorOutput = serde_json::from_value(body).unwrap();
assert_eq!(error.code, Some(omnigraph_server::api::ErrorCode::Conflict));
let conflict = error
.manifest_conflict
.expect("publisher CAS rejection must populate manifest_conflict body");
assert_eq!(conflict.table_key, "node:Person");
assert!(
conflict.actual > conflict.expected,
"actual ({}) should be ahead of expected ({})",
conflict.actual,
conflict.expected,
);
assert_eq!(status, StatusCode::OK);
assert_eq!(body["affected_nodes"], 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn change_concurrent_inserts_same_key_serialize_without_409() {
// PR 2 Phase 2 (MR-686): pin the design fix for the same-key
// concurrency hazard. Pre-fix, in-process concurrent inserts on
// the same `(table, branch)` rejected with 409 manifest_conflict
// because `ensure_expected_version` fired before the per-table
// queue was acquired and saw Lance HEAD already advanced by a
// peer writer. Post-fix, Insert/Merge skip the strict pre-stage
// check (see `MutationOpKind::strict_pre_stage_version_check`);
// the queue serializes commit_staged; Lance's natural rebase
// handles the in-flight stage; the publisher's CAS on a fresh
// per-branch snapshot under the queue catches genuine cross-
// process drift.
// RFC-022 preservation guard: concurrent retryable inserts still all
// succeed, but not by rebasing an already-validated Lance transaction.
// The coarse branch gate serializes effects; a waiter whose authority
// token changed discards its complete attempt and reprepares from the
// winner's committed branch state.
//
// This test spawns N concurrent /change inserts on a single
// node type and asserts: every request returns 200 (no 409),
@ -1270,36 +1250,10 @@ async fn change_concurrent_inserts_same_key_serialize_without_409() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn change_concurrent_updates_same_key_serialize_via_publisher_cas() {
// Pin Update RYW semantics under in-process concurrency on the same
// `(table, branch)`. With per-table queue serialization and op-kind-aware
// drift detection at commit time, exactly one of N concurrent UPDATEs
// on the same row commits; the rest are rejected as 409 manifest_conflict.
//
// Pre-fix bug class: in `MutationStaging::commit_all`, after queue
// acquisition, the staged Lance transaction is handed straight to
// `commit_staged`. For a writer whose staged dataset is at V0 but
// Lance HEAD has advanced to V1 (because the queue's prior winner
// already published), Lance's transaction conflict resolver fires
// `RetryableCommitConflict` on Update vs Update on the same row.
// That error gets wrapped as `OmniError::Lance(<string>)` and the
// API surfaces it as **500 internal**, not 409. Users see "internal
// server error" instead of a retryable conflict, breaking the
// documented 409 contract for in-process drift.
//
// Post-fix invariant: `commit_all` does an op-kind-aware drift check
// before each `commit_staged`. For tables whose tracked op_kind has
// `strict_pre_stage_version_check() == true` (Update / Delete /
// SchemaRewrite), if the staged dataset's version doesn't match the
// fresh manifest pin, return `OmniError::manifest_expected_version_mismatch`
// → 409 ExpectedVersionMismatch. The N-1 losers see a clean 409
// before Lance's commit_staged ever runs.
//
// Why correct-by-design: closing the class "Lance internal conflict
// surfaces as 500 instead of 409" rather than mapping the specific
// Lance error variant. The drift check fires at the right architectural
// layer (engine boundary, under the queue) and respects the existing
// `MutationOpKind` policy.
async fn change_concurrent_updates_same_key_return_typed_pre_effect_conflicts() {
// Strict read-modify-write attempts are never automatically reprepared.
// Exactly one concurrent UPDATE commits; once it changes branch authority,
// every waiter reports a typed 409 before any of its Lance effects begin.
let temp = init_loaded_graph().await;
let graph = graph_path(temp.path());
let state = AppState::open(graph.to_string_lossy().to_string())
@ -1373,27 +1327,37 @@ async fn change_concurrent_updates_same_key_serialize_via_publisher_cas() {
assert_eq!(
ok_count,
1,
"expected exactly one update to commit and N-1 to receive 409 manifest_conflict \
(op-kind-aware drift check rejects stale-V0 staged datasets at commit_all entry). \
Got {} OK + {} 409 + {} other. \
Pre-fix symptom: 1 OK + (N-1) x 500 because Lance's RetryableCommitConflict for \
Update vs Update on the same row bubbles up as `OmniError::Lance(<string>)` and \
the API maps it to 500 internal, not 409. Statuses: {:?}",
"expected exactly one update to commit and N-1 to receive typed 409 conflicts \
before effects. Got {} OK + {} 409 + {} other. Statuses: {:?}",
ok_count,
conflict_count,
statuses.len() - ok_count - conflict_count,
statuses,
);
for (status, bytes) in &results {
if *status != StatusCode::CONFLICT {
continue;
}
let error: ErrorOutput = serde_json::from_slice(bytes).unwrap();
assert_eq!(error.code, Some(omnigraph_server::api::ErrorCode::Conflict));
let conflict = error
.read_set_conflict
.expect("strict OCC loser must include structured read-set authority");
assert_eq!(conflict.member, "graph_head:main");
assert_ne!(conflict.actual, conflict.expected);
assert!(error.manifest_conflict.is_none());
assert!(error.recovery_required.is_none());
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn change_disjoint_table_concurrency_succeeds_at_http_level() {
// HTTP-level pin for MR-686's disjoint-table promise: concurrent /change
// requests touching different node types must coexist without admission
// rejection or publisher-CAS conflict. The bench harness measures
// throughput; this test is the regression sentinel that catches a
// future change which accidentally re-introduces graph-wide
// serialization on the disjoint path.
async fn change_disjoint_table_concurrency_succeeds_under_branch_occ_gate() {
// RFC-022 intentionally serializes effect publication per branch because
// graph-head authority protects validation dependencies across tables.
// Disjoint retryable inserts must nevertheless all succeed through bounded
// full-attempt repreparation, without admission rejection or a user-visible
// publisher conflict.
//
// Setup: test.jsonl seeds 4 Persons + 2 Companies. Spawn N=4 concurrent
// /change inserts on `node:Person` and N=4 concurrent inserts on

View file

@ -391,7 +391,9 @@ const EXPECTED_SCHEMAS: &[&str] = &[
"MergeConflictOutput",
"ReadOutput",
"ReadRequest",
"ReadSetConflictOutput",
"ReadTargetOutput",
"RecoveryRequiredOutput",
"ManifestConflictOutput",
"SchemaApplyOutput",
"SchemaApplyRequest",
@ -614,6 +616,8 @@ fn error_output_schema_has_expected_fields() {
assert!(props.contains_key("code"));
assert!(props.contains_key("merge_conflicts"));
assert!(props.contains_key("manifest_conflict"));
assert!(props.contains_key("read_set_conflict"));
assert!(props.contains_key("recovery_required"));
}
#[test]
@ -626,6 +630,24 @@ fn manifest_conflict_output_schema_has_expected_fields() {
assert!(props.contains_key("actual"));
}
#[test]
fn read_set_conflict_output_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["ReadSetConflictOutput"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("member"));
assert!(props.contains_key("expected"));
assert!(props.contains_key("actual"));
}
#[test]
fn recovery_required_output_schema_has_expected_fields() {
let doc = openapi_json();
let schema = &doc["components"]["schemas"]["RecoveryRequiredOutput"];
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("operation_id"));
}
#[test]
fn commit_output_schema_has_expected_fields() {
let doc = openapi_json();
@ -694,12 +716,21 @@ fn error_code_schema_has_expected_variants() {
let schema = &doc["components"]["schemas"]["ErrorCode"];
let variants = schema["enum"].as_array().unwrap();
let values: HashSet<&str> = variants.iter().map(|v| v.as_str().unwrap()).collect();
assert!(values.contains("unauthorized"));
assert!(values.contains("forbidden"));
assert!(values.contains("bad_request"));
assert!(values.contains("not_found"));
assert!(values.contains("conflict"));
assert!(values.contains("internal"));
assert_eq!(
values,
HashSet::from([
"unauthorized",
"forbidden",
"bad_request",
"not_found",
"method_not_allowed",
"conflict",
"too_many_requests",
"internal",
]),
"ErrorCode is a rolling wire contract: new meanings belong in optional \
structured fields, not new closed-enum values",
);
}
#[test]
@ -919,6 +950,32 @@ fn error_responses_reference_error_output_schema() {
}
}
#[test]
fn recovery_barrier_write_endpoints_document_recovery_required() {
let doc = openapi_json();
for (path, method) in [
("/graphs/{graph_id}/change", "post"),
("/graphs/{graph_id}/mutate", "post"),
("/graphs/{graph_id}/queries/{name}", "post"),
("/graphs/{graph_id}/load", "post"),
("/graphs/{graph_id}/ingest", "post"),
("/graphs/{graph_id}/branches", "post"),
("/graphs/{graph_id}/branches/{branch}", "delete"),
("/graphs/{graph_id}/branches/merge", "post"),
] {
let response = &doc["paths"][path][method]["responses"]["503"];
assert!(
response.is_object(),
"{method} {path} must document the recovery-required 503 outcome"
);
assert_eq!(
response["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/ErrorOutput",
"{method} {path} 503 must use ErrorOutput"
);
}
}
// ---------------------------------------------------------------------------
// Request body reference tests
// ---------------------------------------------------------------------------

View file

@ -371,8 +371,7 @@ async fn schema_apply_route_can_rename_property() {
#[tokio::test]
async fn schema_apply_route_can_add_index() {
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
&fs::read_to_string(fixture("test.pg")).unwrap(),
let (temp, app) = app_for_loaded_graph_with_auth_tokens_and_policy(
&[("act-ragnor", "admin-token")],
SCHEMA_APPLY_POLICY_YAML,
)
@ -392,7 +391,9 @@ async fn schema_apply_route_can_add_index() {
.header("authorization", "Bearer admin-token")
.body(Body::from(
serde_json::to_vec(&SchemaApplyRequest {
schema_source: indexed_name_schema(),
schema_source: fs::read_to_string(fixture("test.pg"))
.unwrap()
.replace("age: I32?", "age: I32? @index"),
..Default::default()
})
.unwrap(),
@ -404,9 +405,24 @@ async fn schema_apply_route_can_add_index() {
assert_eq!(payload["applied"], true);
// iss-848: the /schema/apply route accepts the index-add and applies it as a
// metadata change — it records the `@index` intent in the catalog/IR but does
// NOT build the physical index inline (the build is deferred to
// ensure_indices/optimize; on this empty table nothing would build anyway).
// So the physical index count is unchanged by the apply.
// NOT build the physical index inline or spawn a server-only build (the
// build is deferred to explicit ensure_indices/optimize maintenance).
// Pin the detached-work half structurally instead of waiting an arbitrary
// amount of wall-clock time for a task that must not exist. The physical
// assertion below independently covers synchronous/awaited index builds.
let handlers = include_str!("../src/handlers.rs");
let (_, after_apply_signature) = handlers
.split_once("pub(crate) async fn server_schema_apply")
.expect("server_schema_apply handler must remain present");
let (apply_handler, _) = after_apply_signature
.split_once("/// Shared body for `POST /load`")
.expect("schema apply handler must precede the load handler");
assert!(
!apply_handler.contains("tokio::spawn") && !apply_handler.contains(".ensure_indices("),
"schema apply must not schedule implicit physical index convergence"
);
// The physical index count is unchanged by the apply.
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
let snapshot = reopened
.snapshot_of(ReadTarget::branch("main"))

View file

@ -502,12 +502,6 @@ pub fn renamed_age_schema() -> String {
.replace("age: I32?", "years: I32? @rename_from(\"age\")")
}
pub fn indexed_name_schema() -> String {
fs::read_to_string(fixture("test.pg"))
.unwrap()
.replace("name: String @key", "name: String @key @index")
}
pub fn unsupported_schema_change() -> String {
fs::read_to_string(fixture("test.pg"))
.unwrap()

View file

@ -49,6 +49,11 @@ impl CommitGraph {
/// keeps the cache consistent for same-handle reads, with no storage I/O.
/// Head selection matches the manifest-sourced load (`should_replace_head`).
pub fn insert_committed(&mut self, commit: GraphCommit) {
debug_assert_eq!(
commit.manifest_branch.as_deref(),
self.active_branch.as_deref(),
"published lineage must target the commit graph's active branch"
);
if should_replace_head(self.head_commit.as_ref(), &commit) {
self.head_commit = Some(commit.clone());
}
@ -71,7 +76,8 @@ impl CommitGraph {
let root = root_uri.trim_end_matches('/');
// `load_commit_cache_for_branch` opens the branch's `__manifest` (the
// authoritative table), so a truly absent branch fails loudly here.
let (commit_by_id, head_commit) = load_commit_cache_for_branch(root, Some(branch)).await?;
let (commit_by_id, head_commit) =
load_commit_cache_for_branch(root, Some(branch)).await?;
Ok(Self {
root_uri: root.to_string(),
active_branch: Some(branch.to_string()),
@ -180,7 +186,7 @@ async fn load_commit_cache_from_manifest(
root_uri: &str,
branch: Option<&str>,
) -> Result<(HashMap<String, GraphCommit>, Option<GraphCommit>)> {
let (rows, _heads) =
let (rows, _) =
crate::db::manifest::ManifestCoordinator::read_graph_lineage_at(root_uri, branch).await?;
let mut commit_by_id = HashMap::with_capacity(rows.len());
let mut head_commit = None;

View file

@ -11,7 +11,8 @@ use crate::storage::{StorageAdapter, normalize_root_uri};
use super::commit_graph::{CommitGraph, GraphCommit};
use super::is_internal_system_branch;
use super::manifest::{
ManifestChange, ManifestCoordinator, ManifestIncarnation, Snapshot, SubTableUpdate,
LineageIntent, ManifestChange, ManifestCoordinator, ManifestIncarnation, PublishPrecondition,
Snapshot, SubTableUpdate,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@ -167,6 +168,20 @@ impl GraphCoordinator {
self.manifest.incarnation()
}
/// Lance-native identity of the active `__manifest` branch. Stable across
/// commits; changes when a named branch is deleted and recreated.
pub(crate) async fn branch_identifier(&self) -> Result<lance::dataset::refs::BranchIdentifier> {
self.manifest.branch_identifier().await
}
/// Exact `graph_head:<active-branch>` pointer, preserving `None` for a
/// freshly-created named branch even though its inherited commit history has
/// an inferred head. Sourced from the manifest coordinator's SAME pinned
/// state as [`Self::snapshot`], not the separately refreshed lineage cache.
pub(crate) fn exact_graph_head(&self) -> Option<String> {
self.manifest.exact_graph_head()
}
pub fn snapshot(&self) -> Snapshot {
self.manifest.snapshot()
}
@ -393,10 +408,35 @@ impl GraphCoordinator {
actor_id: Option<&str>,
) -> Result<PublishedSnapshot> {
let intent = self.new_lineage_intent(actor_id, None)?;
self.commit_changes_with_intent_and_expected(
changes,
expected_table_versions,
intent,
&PublishPrecondition::Any,
)
.await
}
/// Publish a pre-minted lineage intent under an explicit authority
/// precondition. The intent's identity and timestamp remain stable across
/// publisher retries and can also be persisted by the caller's recovery
/// protocol before this method is invoked.
pub(crate) async fn commit_changes_with_intent_and_expected(
&mut self,
changes: &[ManifestChange],
expected_table_versions: &HashMap<String, u64>,
intent: LineageIntent,
precondition: &PublishPrecondition,
) -> Result<PublishedSnapshot> {
failpoints::maybe_fail(crate::failpoints::names::GRAPH_PUBLISH_BEFORE_COMMIT_APPEND)?;
let outcome = self
.manifest
.commit_changes_with_lineage(changes, expected_table_versions, Some(&intent))
.commit_changes_with_lineage_and_precondition(
changes,
expected_table_versions,
Some(&intent),
precondition,
)
.await?;
failpoints::maybe_fail(crate::failpoints::names::GRAPH_PUBLISH_AFTER_MANIFEST_COMMIT)?;
let snapshot_id = self.apply_lineage_to_cache(intent, &outcome);
@ -434,12 +474,12 @@ impl GraphCoordinator {
/// fresh ULID (stable across the publisher's CAS retries) and a timestamp.
/// The parent is NOT chosen here — the publisher resolves it per attempt
/// against the manifest it commits against.
fn new_lineage_intent(
pub(crate) fn new_lineage_intent(
&self,
actor_id: Option<&str>,
merged_parent_commit_id: Option<String>,
) -> Result<crate::db::manifest::LineageIntent> {
Ok(crate::db::manifest::LineageIntent {
) -> Result<LineageIntent> {
Ok(LineageIntent {
graph_commit_id: ulid::Ulid::new().to_string(),
branch: self.current_branch().map(str::to_string),
actor_id: actor_id.map(str::to_string),

View file

@ -28,25 +28,26 @@ mod recovery;
mod state;
use graph::{init_manifest_graph, open_manifest_graph, snapshot_state_at};
use layout::{open_manifest_dataset, table_uri_for_path, type_name_hash};
pub(crate) use layout::manifest_uri;
use layout::{open_manifest_dataset, table_uri_for_path, type_name_hash};
pub(crate) use metadata::TableVersionMetadata;
#[cfg(test)]
use metadata::{OMNIGRAPH_ROW_COUNT_KEY, table_version_metadata_for_state};
#[cfg(test)]
use namespace::{branch_manifest_namespace, staged_table_namespace};
pub(crate) use publisher::LineageIntent;
pub(crate) use publisher::{GraphHeadExpectation, LineageIntent, PublishPrecondition};
use publisher::{GraphNamespacePublisher, ManifestBatchPublisher, PublishOutcome};
pub(crate) use recovery::{
RecoveryMode, RecoverySidecar, RecoverySidecarHandle, SidecarKind, SidecarTablePin,
SidecarTableRegistration, SidecarTombstone, confirm_sidecar_phase_b, delete_sidecar,
has_schema_apply_sidecar, heal_pending_sidecars_roll_forward, list_sidecars, new_sidecar,
recover_manifest_drift, schema_apply_serial_queue_key, write_sidecar,
HealPendingOutcome, RecoveryAuthorityToken, RecoveryLineageIntent, RecoveryMode,
RecoverySidecar, RecoverySidecarHandle, SidecarKind, SidecarTablePin, SidecarTableRegistration,
SidecarTombstone, confirm_occ_sidecar_phase_b, confirm_sidecar_phase_b, delete_sidecar,
has_schema_apply_sidecar, heal_pending_sidecars_roll_forward, list_sidecars, new_occ_sidecar,
new_sidecar, recover_manifest_drift, schema_apply_serial_queue_key, write_sidecar,
};
pub use state::SubTableEntry;
pub(crate) use state::{GraphLineageRow, read_graph_lineage};
#[cfg(test)]
use state::string_column;
pub(crate) use state::{GraphLineageRow, read_graph_lineage};
use state::{ManifestState, read_manifest_state};
/// The internal-schema (storage-format) version this binary writes and reads.
@ -498,7 +499,30 @@ impl ManifestCoordinator {
expected_table_versions: &HashMap<String, u64>,
lineage: Option<&LineageIntent>,
) -> Result<CommitOutcome> {
if changes.is_empty() && expected_table_versions.is_empty() && lineage.is_none() {
self.commit_changes_with_lineage_and_precondition(
changes,
expected_table_versions,
lineage,
&PublishPrecondition::Any,
)
.await
}
/// The token-aware form of [`commit_changes_with_lineage`]. Exact authority
/// is checked by the publisher from every CAS attempt's existing one-scan
/// state; legacy callers continue through `Any` above.
pub(crate) async fn commit_changes_with_lineage_and_precondition(
&mut self,
changes: &[ManifestChange],
expected_table_versions: &HashMap<String, u64>,
lineage: Option<&LineageIntent>,
precondition: &PublishPrecondition,
) -> Result<CommitOutcome> {
if changes.is_empty()
&& expected_table_versions.is_empty()
&& lineage.is_none()
&& matches!(precondition, PublishPrecondition::Any)
{
return Ok(CommitOutcome {
version: self.version(),
parent_commit_id: None,
@ -511,7 +535,7 @@ impl ManifestCoordinator {
known_state,
} = self
.publisher
.publish(changes, expected_table_versions, lineage)
.publish_with_precondition(changes, expected_table_versions, lineage, precondition)
.await?;
// RFC-013 PR2 #1b: the publisher folded the new visible state in-memory
// (byte-identical to a re-scan via the shared `assemble_manifest_state`),
@ -550,6 +574,28 @@ impl ManifestCoordinator {
.map_err(|e| OmniError::Lance(e.to_string()))
}
/// Lance-native stable identity for the active manifest branch. Unlike a
/// manifest version/eTag, this remains stable across ordinary commits and
/// changes when a named branch is deleted and recreated (ABA protection).
pub(crate) async fn branch_identifier(&self) -> Result<lance::dataset::refs::BranchIdentifier> {
self.dataset
.branch_identifier()
.await
.map_err(|e| OmniError::Lance(e.to_string()))
}
/// Exact materialized `graph_head:<active-branch>` from the same pinned
/// manifest version as [`Self::snapshot`]. This is write authority, not a
/// lineage-cache query: a read may refresh only the manifest, so consulting
/// `CommitGraph` here would combine a fresh table snapshot with a stale head.
pub(crate) fn exact_graph_head(&self) -> Option<String> {
let branch_key = self
.active_branch
.as_deref()
.unwrap_or(MAIN_BRANCH_HEAD_KEY);
self.known_state.graph_heads.get(branch_key).cloned()
}
pub(crate) fn incarnation(&self) -> ManifestIncarnation {
ManifestIncarnation {
version: self.version(),

View file

@ -36,12 +36,12 @@ use super::metadata::{TableVersionMetadata, parse_namespace_version_request};
use super::migrations::{read_stamp, refuse_if_stamp_unsupported};
use super::state::{
GraphLineageRow, GraphLineageRowPart, ManifestState, assemble_manifest_state,
graph_lineage_row_parts, head_lineage_row, manifest_rows_batch, manifest_schema,
read_manifest_state, read_publish_scan,
graph_head_object_id, graph_lineage_row_parts, head_lineage_row, manifest_rows_batch,
manifest_schema, read_manifest_state, read_publish_scan,
};
use super::{
ManifestChange, OBJECT_TYPE_TABLE, OBJECT_TYPE_TABLE_TOMBSTONE, OBJECT_TYPE_TABLE_VERSION,
SubTableEntry, TableRegistration, TableTombstone,
MAIN_BRANCH_HEAD_KEY, ManifestChange, OBJECT_TYPE_TABLE, OBJECT_TYPE_TABLE_TOMBSTONE,
OBJECT_TYPE_TABLE_VERSION, SubTableEntry, TableRegistration, TableTombstone,
};
/// Bound on the publisher-level retry loop that wraps Lance's row-level CAS
@ -54,11 +54,10 @@ const PUBLISHER_RETRY_BUDGET: u32 = 5;
/// The graph-lineage commit to record atomically with a manifest publish
/// (RFC-013 Phase 7). One logical commit per publish: the `graph_commit_id` is
/// minted once by the caller and stays stable across the publisher's CAS
/// retries; only the parent re-resolves per attempt (against the freshly loaded
/// `__manifest`), so a retry after a concurrent commit parents off the new head
/// — the TOCTOU the dual-write era's `commit_graph.refresh()` guarded is closed
/// by construction.
#[derive(Debug, Clone)]
/// retries. Legacy [`PublishPrecondition::Any`] publishes re-resolve the parent
/// per attempt. An exact-head publish instead rejects a retry once that authority
/// changed, so a prepared write can never be silently re-parented.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct LineageIntent {
/// ULID minted once before the publish loop; the graph commit's identity.
pub graph_commit_id: String,
@ -73,6 +72,55 @@ pub(crate) struct LineageIntent {
pub created_at: i64,
}
/// The exact mutable graph-head authority a prepared write observed. A missing
/// row is first-class: a freshly-created named branch inherits lineage commits
/// but has no `graph_head:<its-name>` until its first graph commit.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct GraphHeadExpectation {
/// `None` means main; `Some("main")` is normalized to `None` by [`new`].
pub(crate) branch: Option<String>,
/// Lance-native stable branch identity. This detects delete/recreate ABA;
/// manifest versions/eTags are deliberately not branch identity.
pub(crate) branch_identifier: lance::dataset::refs::BranchIdentifier,
/// Exact commit id stored in the branch's head row, or `None` when absent.
pub(crate) head_commit_id: Option<String>,
}
impl GraphHeadExpectation {
pub(crate) fn new(
branch: Option<&str>,
branch_identifier: lance::dataset::refs::BranchIdentifier,
head_commit_id: Option<String>,
) -> Self {
Self {
branch: branch
.filter(|branch| *branch != "main")
.map(ToOwned::to_owned),
branch_identifier,
head_commit_id,
}
}
fn object_id(&self) -> String {
graph_head_object_id(self.branch.as_deref())
}
}
/// Authority checked by the manifest publisher on every CAS attempt.
///
/// `Any` preserves the legacy dispatcher semantics: row-level contention may
/// retry and re-parent a lineage intent. `ExactGraphHead` is the RFC-022
/// foundation for prepared writes: after contention, any head movement becomes
/// `ReadSetChanged` rather than a transparent re-parent. Its native branch-id
/// check detects delete/recreate ABA on every attempt; it is not a distributed
/// ref-control fence (Lance branch create/delete still lacks conditional CAS),
/// so branch control remains within the documented single-writer-process bound.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) enum PublishPrecondition {
Any,
ExactGraphHead(GraphHeadExpectation),
}
/// The result of a manifest publish that may have folded in a graph commit.
#[derive(Debug)]
pub(super) struct PublishOutcome {
@ -92,11 +140,29 @@ pub(super) struct PublishOutcome {
#[async_trait]
pub(super) trait ManifestBatchPublisher: Send + Sync {
/// Legacy publish behavior. All existing writers deliberately retain
/// `Any` until enrolled in their RFC-022 adapter.
async fn publish(
&self,
changes: &[ManifestChange],
expected_table_versions: &HashMap<String, u64>,
lineage: Option<&LineageIntent>,
) -> Result<PublishOutcome> {
self.publish_with_precondition(
changes,
expected_table_versions,
lineage,
&PublishPrecondition::Any,
)
.await
}
async fn publish_with_precondition(
&self,
changes: &[ManifestChange],
expected_table_versions: &HashMap<String, u64>,
lineage: Option<&LineageIntent>,
precondition: &PublishPrecondition,
) -> Result<PublishOutcome>;
}
@ -119,15 +185,16 @@ struct PendingVersionRow {
/// Everything one CAS attempt needs out of a single `__manifest` scan
/// (RFC-013 P2): the open dataset, table state for the pre-check + pending-row
/// build, and the `graph_commit` lineage rows for parent resolution. Folding the
/// lineage into this struct is what lets `resolve_lineage_rows` skip its own
/// `read_graph_lineage` scan.
/// build, `graph_commit` lineage rows for parent resolution, and exact
/// `graph_head` rows for OCC. Folding lineage authority into this struct is what
/// lets both checks skip their own `read_graph_lineage` scan.
struct LoadedPublishState {
dataset: Dataset,
registered_tables: HashMap<String, String>,
existing_versions: HashMap<(String, u64), SubTableEntry>,
existing_tombstones: HashMap<(String, u64), ()>,
lineage_rows: Vec<GraphLineageRow>,
graph_heads: HashMap<String, String>,
}
impl GraphNamespacePublisher {
@ -159,12 +226,11 @@ impl GraphNamespacePublisher {
// `db/manifest/migrations.rs`.
refuse_if_stamp_unsupported(read_stamp(&dataset))?;
// ONE `__manifest` scan for everything the publish needs: table
// locations, version entries, tombstones, AND the `graph_commit` lineage
// rows for parent resolution (RFC-013 P2). The lineage extraction rides
// this pass instead of a second `read_graph_lineage` scan in
// `resolve_lineage_rows`; the per-attempt re-read is preserved because
// `load_publish_state` runs once per CAS attempt, so a retry sees the
// advanced head and re-parents correctly.
// locations, version entries, tombstones, `graph_commit` lineage rows
// for parent resolution, AND exact `graph_head` rows for OCC (RFC-013
// P2 / RFC-022). Extraction rides this pass instead of a second
// `read_graph_lineage` scan; the per-attempt re-read is preserved because
// `load_publish_state` runs once per CAS attempt.
let scan = read_publish_scan(&dataset).await?;
let existing_versions = scan
.version_entries
@ -183,6 +249,7 @@ impl GraphNamespacePublisher {
existing_versions,
existing_tombstones,
lineage_rows: scan.lineage_rows,
graph_heads: scan.graph_heads,
})
}
@ -506,12 +573,15 @@ impl GraphNamespacePublisher {
))
})?;
let table_path =
table_locations.get(&row.table_key).cloned().ok_or_else(|| {
OmniError::manifest_internal(format!(
"post-publish fold: missing table row for {}",
row.table_key
))
})?;
table_locations
.get(&row.table_key)
.cloned()
.ok_or_else(|| {
OmniError::manifest_internal(format!(
"post-publish fold: missing table row for {}",
row.table_key
))
})?;
let metadata_json = row.metadata.as_deref().ok_or_else(|| {
OmniError::manifest_internal(format!(
"post-publish fold: table_version row missing metadata for {}",
@ -572,6 +642,80 @@ impl GraphNamespacePublisher {
Ok(())
}
/// Check authority inside the publisher retry loop before pending rows are
/// built. The graph head comes from the SAME scan used to build this CAS
/// attempt; Lance's native branch identifier is re-read from the ref. Thus a
/// row-level-CAS loser with an exact expectation cannot silently re-parent
/// on its next attempt.
async fn check_publish_precondition(
&self,
dataset: &Dataset,
graph_heads: &HashMap<String, String>,
precondition: &PublishPrecondition,
) -> Result<()> {
let PublishPrecondition::ExactGraphHead(expected) = precondition else {
return Ok(());
};
let expected_branch = expected
.branch
.as_deref()
.filter(|branch| *branch != "main");
if expected_branch != self.branch.as_deref() {
return Err(OmniError::manifest_internal(format!(
"publish graph-head precondition targets branch '{}' but publisher is bound to '{}'",
expected_branch.unwrap_or("main"),
self.branch.as_deref().unwrap_or("main"),
)));
}
let branch_identity_member =
format!("branch_identifier:{}", expected_branch.unwrap_or("main"));
let expected_branch_identifier = serde_json::to_string(&expected.branch_identifier)
.map_err(|e| {
OmniError::manifest_internal(format!(
"failed to encode expected Lance branch identifier: {e}"
))
})?;
let actual_branch_identifier = match dataset.branch_identifier().await {
Ok(identifier) => identifier,
Err(LanceError::RefNotFound { .. }) => {
return Err(OmniError::manifest_read_set_changed(
branch_identity_member,
Some(expected_branch_identifier),
None,
));
}
Err(err) => return Err(OmniError::Lance(err.to_string())),
};
if actual_branch_identifier != expected.branch_identifier {
let actual = serde_json::to_string(&actual_branch_identifier).map_err(|e| {
OmniError::manifest_internal(format!(
"failed to encode current Lance branch identifier: {e}"
))
})?;
return Err(OmniError::manifest_read_set_changed(
branch_identity_member,
Some(expected_branch_identifier),
Some(actual),
));
}
let object_id = expected.object_id();
let branch_key = object_id
.strip_prefix("graph_head:")
.expect("graph_head_object_id always supplies the prefix");
let actual = graph_heads.get(branch_key).cloned();
if actual != expected.head_commit_id {
return Err(OmniError::manifest_read_set_changed(
object_id,
expected.head_commit_id.clone(),
actual,
));
}
Ok(())
}
async fn merge_rows(&self, dataset: Dataset, rows: Vec<PendingVersionRow>) -> Result<Dataset> {
let batch = Self::pending_rows_to_batch(rows)?;
let reader = RecordBatchIterator::new(vec![Ok(batch)], manifest_schema());
@ -671,13 +815,18 @@ pub(crate) fn map_lance_publish_error(err: LanceError) -> OmniError {
#[async_trait]
impl ManifestBatchPublisher for GraphNamespacePublisher {
async fn publish(
async fn publish_with_precondition(
&self,
changes: &[ManifestChange],
expected_table_versions: &HashMap<String, u64>,
lineage: Option<&LineageIntent>,
precondition: &PublishPrecondition,
) -> Result<PublishOutcome> {
if changes.is_empty() && expected_table_versions.is_empty() && lineage.is_none() {
if changes.is_empty()
&& expected_table_versions.is_empty()
&& lineage.is_none()
&& matches!(precondition, PublishPrecondition::Any)
{
// Defensive no-op (never reached from `commit_changes_with_lineage`,
// which short-circuits the all-empty case): state is unchanged, so a
// re-scan here is acceptable.
@ -709,8 +858,16 @@ impl ManifestBatchPublisher for GraphNamespacePublisher {
existing_versions,
existing_tombstones,
lineage_rows,
graph_heads,
} = loaded;
// Exact logical authority is checked on EVERY attempt from this
// attempt's single manifest scan. In particular, a CAS retry after
// another writer creates or advances `graph_head:<branch>` fails
// here instead of transparently re-parenting the prepared intent.
self.check_publish_precondition(&dataset, &graph_heads, precondition)
.await?;
let latest_per_table =
Self::latest_visible_per_table(&existing_versions, &existing_tombstones);
// Pre-check on every attempt against freshly loaded state so a
@ -754,6 +911,7 @@ impl ManifestBatchPublisher for GraphNamespacePublisher {
existing_tombstones
.keys()
.map(|(key, version)| (key.clone(), *version)),
graph_heads,
);
return Ok(PublishOutcome {
dataset,
@ -765,8 +923,23 @@ impl ManifestBatchPublisher for GraphNamespacePublisher {
// Build the post-publish fold inputs from the pre-publish state the
// rows we are about to commit, BEFORE `rows` is moved into merge_rows
// (RFC-013 PR2 #1b). Recomputed per attempt from freshly-loaded state.
let (fold_entries, fold_tombstones) =
Self::fold_inputs(&existing_versions, &existing_tombstones, &rows, &known_tables)?;
let (fold_entries, fold_tombstones) = Self::fold_inputs(
&existing_versions,
&existing_tombstones,
&rows,
&known_tables,
)?;
let mut fold_graph_heads = graph_heads;
if let Some(intent) = lineage {
fold_graph_heads.insert(
intent
.branch
.as_deref()
.unwrap_or(MAIN_BRANCH_HEAD_KEY)
.to_string(),
intent.graph_commit_id.clone(),
);
}
match self.merge_rows(dataset, rows).await {
Ok(new_dataset) => {
@ -774,6 +947,7 @@ impl ManifestBatchPublisher for GraphNamespacePublisher {
new_dataset.version().version,
fold_entries,
fold_tombstones,
fold_graph_heads,
);
return Ok(PublishOutcome {
dataset: new_dataset,

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,11 @@ pub struct SubTableEntry {
pub(super) struct ManifestState {
pub(super) version: u64,
pub(super) entries: Vec<SubTableEntry>,
/// Exact materialized `graph_head:<branch>` values from this SAME manifest
/// version. Keeping the head beside the table snapshot prevents a
/// manifest-only refresh from leaving coarse write authority split between
/// a fresh table view and the commit graph's older derived cache.
pub(super) graph_heads: HashMap<String, String>,
}
#[derive(Debug, Clone)]
@ -91,10 +96,13 @@ struct ManifestScan {
/// the caller asked (`collect_lineage`). Empty on the table-state read hot
/// path so it never pays the O(commits) lineage JSON decode; populated on the
/// publish path, where `load_publish_state` already needs the parent and would
/// otherwise scan `__manifest` a second time via `read_graph_lineage`. `graph_head`
/// rows are not collected here — parent resolution uses the head-over-commits
/// computation, not the denormalized head pointer (see `resolve_lineage_rows`).
/// otherwise scan `__manifest` a second time via `read_graph_lineage`.
lineage_rows: Vec<GraphLineageRow>,
/// Exact materialized `graph_head:<branch>` values, collected on every
/// table-state/publish pass. This is bounded by branch count; unlike
/// `lineage_rows`, it does not grow with commit history. OCC must distinguish
/// a present head from an absent one (notably on a fresh named branch).
graph_heads: HashMap<String, String>,
}
pub(super) fn manifest_schema() -> SchemaRef {
@ -137,6 +145,7 @@ pub(super) async fn read_manifest_state(dataset: &Dataset) -> Result<ManifestSta
scan.tombstones
.into_iter()
.map(|t| (t.table_key, t.tombstone_version)),
scan.graph_heads,
))
}
@ -152,6 +161,7 @@ pub(super) fn assemble_manifest_state(
version: u64,
version_entries: Vec<SubTableEntry>,
tombstones: impl IntoIterator<Item = (String, u64)>,
graph_heads: HashMap<String, String>,
) -> ManifestState {
let mut latest_versions = HashMap::<String, SubTableEntry>::new();
for entry in version_entries {
@ -183,7 +193,11 @@ pub(super) fn assemble_manifest_state(
})
.collect();
entries.sort_by(|a, b| a.table_key.cmp(&b.table_key));
ManifestState { version, entries }
ManifestState {
version,
entries,
graph_heads,
}
}
// After RFC-013 P2 folded the publish path off this accessor (it now projects
@ -208,6 +222,9 @@ pub(super) struct PublishScan {
pub(super) version_entries: Vec<SubTableEntry>,
pub(super) tombstones: Vec<((String, u64), ())>,
pub(super) lineage_rows: Vec<GraphLineageRow>,
/// Exact `graph_head:<branch>` rows keyed by the branch suffix (`main` for
/// main). Absence is meaningful and is preserved by a missing map entry.
pub(super) graph_heads: HashMap<String, String>,
}
/// One-scan read of everything the publish path needs. `collect_lineage` is
@ -224,6 +241,7 @@ pub(super) async fn read_publish_scan(dataset: &Dataset) -> Result<PublishScan>
.map(|tombstone| ((tombstone.table_key, tombstone.tombstone_version), ()))
.collect(),
lineage_rows: scan.lineage_rows,
graph_heads: scan.graph_heads,
})
}
@ -264,14 +282,43 @@ fn decode_graph_commit_row(
})
}
/// Decode one `graph_head` row into its exact branch-key / commit-id pair.
/// Shared by the dedicated lineage reader and the publisher's folded one-scan
/// path so presence, absence, and malformed-row handling cannot drift.
fn decode_graph_head_row(
object_ids: &StringArray,
metadata: &StringArray,
row: usize,
) -> Result<(String, String)> {
if metadata.is_null(row) {
return Err(OmniError::manifest_internal(format!(
"manifest graph_head row missing metadata for {}",
object_ids.value(row)
)));
}
let head_meta: GraphHeadMetadata = serde_json::from_str(metadata.value(row)).map_err(|e| {
OmniError::manifest_internal(format!("failed to decode graph_head metadata: {e}"))
})?;
let branch_key = object_ids
.value(row)
.strip_prefix("graph_head:")
.ok_or_else(|| {
OmniError::manifest_internal(format!(
"invalid graph_head object id {}",
object_ids.value(row)
))
})?
.to_string();
Ok((branch_key, head_meta.head_commit_id))
}
async fn read_manifest_scan(dataset: &Dataset, collect_lineage: bool) -> Result<ManifestScan> {
// Project only the columns the assembly below reads (RFC-013 PR2 #1c). The
// table-state hot path never touches `object_id` (lineage decode only) or
// `base_objects` (reserved/unused — never read on any path), so reading them
// is wasted bytes on every `__manifest` scan — write publish AND every
// branch-op open. Mirrors Lance's own directory-catalog `__manifest` reads,
// which project to the needed columns rather than scanning all of them.
let mut projection: Vec<&str> = vec![
// `object_id` is needed for the bounded graph-head authority decode on every
// path; `base_objects` remains reserved/unused. Mirrors Lance's own
// directory-catalog `__manifest` reads, which project only needed columns.
let projection: Vec<&str> = vec![
"object_id",
"object_type",
"location",
"metadata",
@ -280,9 +327,6 @@ async fn read_manifest_scan(dataset: &Dataset, collect_lineage: bool) -> Result<
"table_branch",
"row_count",
];
if collect_lineage {
projection.push("object_id");
}
let mut scanner = dataset.scan();
scanner
.project(&projection)
@ -299,6 +343,7 @@ async fn read_manifest_scan(dataset: &Dataset, collect_lineage: bool) -> Result<
let mut version_entries = Vec::new();
let mut tombstones = Vec::new();
let mut lineage_rows = Vec::new();
let mut graph_heads = HashMap::new();
for batch in &batches {
let object_types = string_column(batch, "object_type")?;
@ -308,13 +353,11 @@ async fn read_manifest_scan(dataset: &Dataset, collect_lineage: bool) -> Result<
let versions = u64_column(batch, "table_version")?;
let branches = string_column(batch, "table_branch")?;
let row_counts = u64_column(batch, "row_count")?;
// `object_id` is only needed for lineage decoding; skip the lookup
// entirely on the table-state hot path (`collect_lineage == false`).
let object_ids = if collect_lineage {
Some(string_column(batch, "object_id")?)
} else {
None
};
// `object_id` is needed for the exact graph-head authority even on the
// table-state path. We still skip every `graph_commit` decode there, so
// the added work is bounded by the number of branch-head rows rather
// than growing with commit history.
let object_ids = string_column(batch, "object_id")?;
for row in 0..batch.num_rows() {
let table_key = table_keys.value(row).to_string();
@ -358,21 +401,22 @@ async fn read_manifest_scan(dataset: &Dataset, collect_lineage: bool) -> Result<
tombstone_version,
});
}
// `graph_commit` rows (RFC-013) are decoded into the scan ONLY
// when `collect_lineage` is set (the publish path, which resolves
// a parent). The table-state hot path leaves them — and
// `graph_head` + any future object type — in the `_` arm so it
// never pays the O(commits) lineage JSON decode. When NOT
// collecting, `object_ids` is `None`, so this arm is the same
// forward-compat skip as the `_` arm.
// `graph_commit` rows (RFC-013) are decoded ONLY for the publish
// path, which resolves a parent. The table-state hot path skips
// them, while still decoding the bounded graph-head authority
// rows below.
OBJECT_TYPE_GRAPH_COMMIT if collect_lineage => {
let object_ids = object_ids.expect("object_ids read when collect_lineage");
lineage_rows.push(decode_graph_commit_row(
object_ids, metadata, versions, branches, row,
)?);
}
// Skipped on the table-state path (and for `graph_head` / unknown
// future object types on every path): no table snapshot needs them.
OBJECT_TYPE_GRAPH_HEAD => {
let (branch_key, head_commit_id) =
decode_graph_head_row(object_ids, metadata, row)?;
graph_heads.insert(branch_key, head_commit_id);
}
// Commit rows are skipped on the table-state path; unknown future
// object types are skipped on every path.
_ => {}
}
}
@ -404,6 +448,7 @@ async fn read_manifest_scan(dataset: &Dataset, collect_lineage: bool) -> Result<
version_entries: entries,
tombstones,
lineage_rows,
graph_heads,
})
}
@ -447,26 +492,9 @@ pub(crate) async fn read_graph_lineage(
)?);
}
OBJECT_TYPE_GRAPH_HEAD => {
if metadata.is_null(row) {
return Err(OmniError::manifest_internal(format!(
"manifest graph_head row missing metadata for {}",
object_ids.value(row)
)));
}
let head_meta: GraphHeadMetadata = serde_json::from_str(metadata.value(row))
.map_err(|e| {
OmniError::manifest_internal(format!(
"failed to decode graph_head metadata: {e}"
))
})?;
// `object_id` is `graph_head:<branch>`; the branch key after
// the prefix is the projection's map key (`main` for main).
let branch_key = object_ids
.value(row)
.strip_prefix("graph_head:")
.unwrap_or_default()
.to_string();
graph_heads.insert(branch_key, head_meta.head_commit_id);
let (branch_key, head_commit_id) =
decode_graph_head_row(object_ids, metadata, row)?;
graph_heads.insert(branch_key, head_commit_id);
}
_ => {}
}
@ -623,7 +651,6 @@ pub(super) fn entries_to_batch(
)
}
pub(super) fn manifest_rows_batch(
object_ids: Vec<String>,
object_types: Vec<String>,

View file

@ -12,7 +12,11 @@ use lance_namespace::models::{
use lance_namespace_impls::DirectoryNamespaceBuilder;
use tokio::sync::Mutex;
use super::publisher::{LineageIntent, ManifestBatchPublisher, PublishOutcome};
use super::publisher::{
GraphHeadExpectation, LineageIntent, ManifestBatchPublisher, PublishOutcome,
PublishPrecondition,
};
use super::state::read_publish_scan;
use super::*;
use omnigraph_compiler::catalog::build_catalog;
use omnigraph_compiler::schema::parser::parse_schema;
@ -154,10 +158,11 @@ async fn test_commit_changes_can_register_new_table_and_tombstone_old_one() {
let ds = crate::table_store::TableStore::create_empty_dataset(&dataset_uri, &schema)
.await
.unwrap();
let state = crate::table_store::TableStore::new(uri, Arc::new(lance::session::Session::default()))
.table_state(&dataset_uri, &ds)
.await
.unwrap();
let state =
crate::table_store::TableStore::new(uri, Arc::new(lance::session::Session::default()))
.table_state(&dataset_uri, &ds)
.await
.unwrap();
mc.commit_changes(&[
ManifestChange::RegisterTable(TableRegistration {
@ -611,7 +616,10 @@ async fn test_branch_manifest_namespace_uses_entry_owner_branch_for_latest_table
.with_branch("feature", None)
.load()
.await;
let err = format!("{:?}", mismatched.expect_err("branch-mismatched open must fail on v9"));
let err = format!(
"{:?}",
mismatched.expect_err("branch-mismatched open must fail on v9")
);
assert!(
err.contains("belonging to branch"),
"expected the v9 branch-consistency error, got: {err}"
@ -1148,11 +1156,12 @@ impl RecordingPublisher {
#[async_trait]
impl ManifestBatchPublisher for RecordingPublisher {
async fn publish(
async fn publish_with_precondition(
&self,
changes: &[ManifestChange],
expected_table_versions: &HashMap<String, u64>,
lineage: Option<&LineageIntent>,
precondition: &PublishPrecondition,
) -> Result<PublishOutcome> {
let requests: Vec<CreateTableVersionRequest> = changes
.iter()
@ -1163,7 +1172,7 @@ impl ManifestBatchPublisher for RecordingPublisher {
.collect();
self.requests.lock().await.extend_from_slice(&requests);
self.inner
.publish(changes, expected_table_versions, lineage)
.publish_with_precondition(changes, expected_table_versions, lineage, precondition)
.await
}
}
@ -1172,11 +1181,12 @@ struct FailingPublisher;
#[async_trait]
impl ManifestBatchPublisher for FailingPublisher {
async fn publish(
async fn publish_with_precondition(
&self,
_changes: &[ManifestChange],
_expected_table_versions: &HashMap<String, u64>,
_lineage: Option<&LineageIntent>,
_precondition: &PublishPrecondition,
) -> Result<PublishOutcome> {
Err(OmniError::manifest(
"injected batch publisher failure".to_string(),
@ -1760,7 +1770,9 @@ async fn sub_current_graph_is_refused_on_open_with_rebuild_hint() {
// (v4) cannot open, since `MIN_SUPPORTED == CURRENT == 4`.
{
let mut ds = open_manifest_dataset(uri, None).await.unwrap();
super::migrations::set_stamp_for_test(&mut ds, 3).await.unwrap();
super::migrations::set_stamp_for_test(&mut ds, 3)
.await
.unwrap();
}
// Read-write open is refused with the rebuild hint.
@ -1820,7 +1832,9 @@ async fn sub_current_graph_is_refused_then_rebuilt_via_export_import() {
// Make it look like a graph from an older release: rewind the stamp below CURRENT.
{
let mut ds = open_manifest_dataset(uri_old, None).await.unwrap();
super::migrations::set_stamp_for_test(&mut ds, 3).await.unwrap();
super::migrations::set_stamp_for_test(&mut ds, 3)
.await
.unwrap();
}
let err = match Omnigraph::open(uri_old).await {
Ok(_) => panic!("a sub-CURRENT graph must be refused on open"),
@ -1866,12 +1880,11 @@ async fn sub_current_graph_is_refused_then_rebuilt_via_export_import() {
// Two (or N) writers committing DISJOINT tables on the same branch still share
// one mutable `graph_head:main` row (one `object_id`, `WhenMatched::UpdateAll`).
// Their table-version rows never collide (distinct `object_id`s), so the *only*
// row-level CAS contention is on `graph_head:main`. The contract under test:
// exactly one writer wins each CAS round; the loser retries, re-resolves its
// parent off the freshly-advanced head (inside the publisher's retry loop), and
// re-commits — so every writer commits and the resulting graph_commit DAG is a
// single LINEAR chain (no fork), not a tree. This is the cross-process
// disjoint-table fork closed by the shared head row (invariants.md §7.1).
// row-level CAS contention is on `graph_head:main`. Two contracts coexist:
// legacy `Any` publishers retry, re-parent, and eventually form one linear DAG;
// RFC-022 `ExactGraphHead` publishers instead reject the stale prepared write
// after the first winner changes authority. Both rely on the same shared row to
// prevent a fork; the tests below pin both behaviors explicitly.
/// A microsecond UNIX timestamp for a `LineageIntent`, matching the genesis /
/// commit-graph `created_at` unit.
@ -1882,6 +1895,180 @@ fn lineage_now_micros() -> i64 {
.as_micros() as i64
}
/// Race two lineage-only publishes against the same exact named-branch head.
/// Returns after proving exactly one committed and the loser surfaced a typed
/// read-set change. `establish_head=false` exercises the load-bearing absent-row
/// case on a fresh branch; `true` exercises ordinary head advancement.
async fn assert_exact_named_head_race(establish_head: bool) {
use crate::error::ManifestConflictDetails;
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
let catalog = build_test_catalog();
let mut mc = ManifestCoordinator::init(uri, &catalog).await.unwrap();
mc.create_branch("feature").await.unwrap();
let publisher = GraphNamespacePublisher::new(uri, Some("feature"));
let empty = HashMap::new();
let expected_head = if establish_head {
let intent = LineageIntent {
graph_commit_id: ulid::Ulid::new().to_string(),
branch: Some("feature".to_string()),
actor_id: None,
merged_parent_commit_id: None,
created_at: lineage_now_micros(),
};
publisher
.publish(&[], &empty, Some(&intent))
.await
.expect("establish named-branch graph head");
Some(intent.graph_commit_id)
} else {
None
};
// The folded publish scan must preserve exact absence. In particular, the
// inferred lineage head inherited from main must not masquerade as a
// materialized `graph_head:feature` row.
let branch_manifest = open_manifest_dataset(uri, Some("feature")).await.unwrap();
let scan = read_publish_scan(&branch_manifest).await.unwrap();
assert_eq!(scan.graph_heads.get("feature").cloned(), expected_head);
let branch_identifier = branch_manifest.branch_identifier().await.unwrap();
let precondition = PublishPrecondition::ExactGraphHead(GraphHeadExpectation::new(
Some("feature"),
branch_identifier,
expected_head.clone(),
));
let intent_a = LineageIntent {
graph_commit_id: ulid::Ulid::new().to_string(),
branch: Some("feature".to_string()),
actor_id: Some("act-a".to_string()),
merged_parent_commit_id: None,
created_at: lineage_now_micros(),
};
let intent_b = LineageIntent {
graph_commit_id: ulid::Ulid::new().to_string(),
branch: Some("feature".to_string()),
actor_id: Some("act-b".to_string()),
merged_parent_commit_id: None,
created_at: lineage_now_micros(),
};
let publisher_a = GraphNamespacePublisher::new(uri, Some("feature"));
let publisher_b = GraphNamespacePublisher::new(uri, Some("feature"));
let precondition_a = precondition.clone();
let precondition_b = precondition;
let (result_a, result_b) = tokio::join!(
async {
publisher_a
.publish_with_precondition(&[], &empty, Some(&intent_a), &precondition_a)
.await
},
async {
publisher_b
.publish_with_precondition(&[], &empty, Some(&intent_b), &precondition_b)
.await
}
);
let (winner_id, loser_error) = match (result_a, result_b) {
(Ok(_), Err(err)) => (intent_a.graph_commit_id.clone(), err),
(Err(err), Ok(_)) => (intent_b.graph_commit_id.clone(), err),
(Ok(_), Ok(_)) => panic!("exact-head race silently re-parented both writers"),
(Err(a), Err(b)) => panic!("both exact-head writers failed: {a:?} / {b:?}"),
};
let OmniError::Manifest(error) = loser_error else {
panic!("expected typed manifest conflict, got {loser_error:?}");
};
assert_eq!(
error.details,
Some(ManifestConflictDetails::ReadSetChanged {
member: "graph_head:feature".to_string(),
expected: expected_head,
actual: Some(winner_id.clone()),
})
);
let branch_manifest = open_manifest_dataset(uri, Some("feature")).await.unwrap();
let (commits, heads) = read_graph_lineage(&branch_manifest).await.unwrap();
assert_eq!(heads.get("feature"), Some(&winner_id));
assert_eq!(
commits
.iter()
.filter(|commit| {
commit.graph_commit_id == intent_a.graph_commit_id
|| commit.graph_commit_id == intent_b.graph_commit_id
})
.count(),
1,
"the rejected intent must not leave an immutable graph_commit row"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exact_head_publish_rejects_reparent_on_fresh_and_established_named_branch() {
assert_exact_named_head_race(false).await;
assert_exact_named_head_race(true).await;
}
#[tokio::test]
async fn exact_publish_rejects_named_branch_delete_recreate_aba() {
use crate::error::ManifestConflictDetails;
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
let catalog = build_test_catalog();
let mut mc = ManifestCoordinator::init(uri, &catalog).await.unwrap();
mc.create_branch("feature").await.unwrap();
let old_branch = open_manifest_dataset(uri, Some("feature")).await.unwrap();
let old_identifier = old_branch.branch_identifier().await.unwrap();
assert!(
read_publish_scan(&old_branch)
.await
.unwrap()
.graph_heads
.get("feature")
.is_none(),
"fresh named branch starts without its own graph_head row"
);
// Recreate the same name at the same logical fork point. Numeric manifest
// version and exact graph-head absence can repeat; only Lance's native
// branch identifier distinguishes the incarnation.
mc.delete_branch("feature").await.unwrap();
mc.create_branch("feature").await.unwrap();
let recreated = open_manifest_dataset(uri, Some("feature")).await.unwrap();
let recreated_identifier = recreated.branch_identifier().await.unwrap();
assert_ne!(old_identifier, recreated_identifier);
let precondition = PublishPrecondition::ExactGraphHead(GraphHeadExpectation::new(
Some("feature"),
old_identifier,
None,
));
let err = GraphNamespacePublisher::new(uri, Some("feature"))
.publish_with_precondition(&[], &HashMap::new(), None, &precondition)
.await
.expect_err("recreated branch must reject the old incarnation token");
let OmniError::Manifest(error) = err else {
panic!("expected typed manifest conflict, got {err:?}");
};
match error.details {
Some(ManifestConflictDetails::ReadSetChanged {
member,
expected: Some(expected),
actual: Some(actual),
}) => {
assert_eq!(member, "branch_identifier:feature");
assert_ne!(expected, actual);
}
other => panic!("expected branch-identifier ReadSetChanged, got {other:?}"),
}
}
/// Append one row to a two-column NODE table (`id`, `name`) and return the
/// resulting `SubTableUpdate` at the new on-disk version. Generalizes
/// `append_person_and_make_update` to any node table whose schema is `(id:
@ -1943,8 +2130,10 @@ async fn assert_linear_chain(uri: &str, expected_total: usize) -> String {
);
// (1) exactly one genesis.
let genesis: Vec<&GraphLineageRow> =
rows.iter().filter(|r| r.parent_commit_id.is_none()).collect();
let genesis: Vec<&GraphLineageRow> = rows
.iter()
.filter(|r| r.parent_commit_id.is_none())
.collect();
assert_eq!(
genesis.len(),
1,
@ -2038,8 +2227,16 @@ async fn concurrent_disjoint_writes_share_head_and_form_linear_chain() {
// version on the other's table; contention is purely the shared head row.
let empty = HashMap::new();
let (res_a, res_b) = tokio::join!(
async { publisher_a.publish(&changes_a, &empty, Some(&intent_a)).await },
async { publisher_b.publish(&changes_b, &empty, Some(&intent_b)).await }
async {
publisher_a
.publish(&changes_a, &empty, Some(&intent_a))
.await
},
async {
publisher_b
.publish(&changes_b, &empty, Some(&intent_b))
.await
}
);
// BOTH commit: disjoint tables → the head-row CAS loser retries within
@ -2114,8 +2311,16 @@ async fn concurrent_disjoint_writes_form_linear_chain_on_s3() {
};
let empty = HashMap::new();
let (res_a, res_b) = tokio::join!(
async { publisher_a.publish(&changes_a, &empty, Some(&intent_a)).await },
async { publisher_b.publish(&changes_b, &empty, Some(&intent_b)).await }
async {
publisher_a
.publish(&changes_a, &empty, Some(&intent_a))
.await
},
async {
publisher_b
.publish(&changes_b, &empty, Some(&intent_b))
.await
}
);
res_a.expect("writer A must commit on S3");
res_b.expect("writer B must commit on S3");
@ -2161,8 +2366,13 @@ async fn n_concurrent_disjoint_writers_converge_to_one_linear_chain() {
.await,
);
updates.push(
append_node_row_and_make_update(uri, &company_entry, &format!("c{i}"), &format!("C{i}"))
.await,
append_node_row_and_make_update(
uri,
&company_entry,
&format!("c{i}"),
&format!("C{i}"),
)
.await,
);
}
assert_eq!(updates.len(), N);
@ -2216,7 +2426,11 @@ async fn n_concurrent_disjoint_writers_converge_to_one_linear_chain() {
// All 8 distinct writer ids committed (no lost commit, no duplicate id).
committed_ids.sort();
committed_ids.dedup();
assert_eq!(committed_ids.len(), N, "every writer must commit exactly once");
assert_eq!(
committed_ids.len(),
N,
"every writer must commit exactly once"
);
// The final DAG is a single linear chain of genesis + 8 = 9, no fork.
assert_linear_chain(uri, N + 1).await;

View file

@ -10,7 +10,7 @@ pub use commit_graph::GraphCommit;
pub use graph_coordinator::{GraphCoordinator, ReadTarget, ResolvedTarget, SnapshotId};
pub use manifest::{Snapshot, SubTableEntry, SubTableUpdate};
pub(crate) use omnigraph::ensure_public_branch_ref;
pub(crate) use omnigraph::WriteTxn;
pub(crate) use omnigraph::{DeferredTableFork, WriteTxn};
pub use omnigraph::{
CleanupPolicyOptions, InitOptions, MergeOutcome, Omnigraph, OpenMode, PendingIndex,
RepairAction, RepairClassification, RepairOptions, RepairStats, SchemaApplyOptions,
@ -21,30 +21,27 @@ use crate::error::{OmniError, Result};
pub(crate) const SCHEMA_APPLY_LOCK_BRANCH: &str = "__schema_apply_lock__";
/// Mutation kind, threaded through the version-check call sites so the
/// engine can apply an op-kind-aware policy:
/// Mutation kind, threaded through the early table-version checks so the
/// engine can apply an op-kind-aware staging policy. This check is not the
/// RFC-022 publish authority: enrolled mutation/load attempts additionally
/// capture an exact branch-wide `WriteTxn`, then revalidate it while holding
/// the root-shared schema → branch → sorted-table gates.
///
/// - `Insert` / `Merge`: skip the strict pre-stage `ensure_expected_version`
/// check. Lance's `MergeInsertBuilder` rebases concurrent appends; the
/// per-(table, branch) writer queue serializes `commit_staged`; the
/// publisher's CAS (refreshed under the queue via
/// `MutationStaging::commit_all`'s `snapshot_for_branch` call) catches
/// genuine cross-process drift as `ManifestConflictDetails::ExpectedVersionMismatch`.
/// The pre-stage strict check would over-reject in-process concurrent
/// inserts, which is exactly the case PR 2 / MR-686 designed the
/// per-table queue to allow.
/// check because their staged files are reclaimable and the complete prepared
/// attempt is checked later against the exact branch authority. On a
/// pre-effect `ReadSetChanged`, mutation Insert and load Append/Merge discard
/// the whole attempt and reprepare with a bounded retry; they never patch
/// table pins beneath an already-validated plan.
///
/// - `Update` / `Delete`: keep the strict check. These have read-modify-write
/// semantics; Lance moving between the read at stage time and the write
/// at commit time means the staged batch is computed against stale state.
/// The strict check guards the per-query SI invariant. SERIALIZABLE
/// opt-in (§VI.36 future seam) is the long-term answer for tighter
/// semantics; today, in-process update-update races on the same key
/// stay rejected as 409 — acceptable.
/// - `Update` / `Delete`: keep the strict early check because these are
/// read-modify-write effects computed from a pinned image. Enrolled attempts
/// still perform the later branch-wide revalidation; a mismatch is strict
/// `ReadSetChanged` (HTTP 409), not a transparent replay.
///
/// - `SchemaRewrite`: keep the strict check. Schema apply runs under the
/// graph-wide `__schema_apply_lock__` AND per-table queues; the strict
/// check is uncontested at that point.
/// - `SchemaRewrite`: keep the strict early check for overwrite/rewrite effects.
/// An enrolled load Overwrite also uses the exact branch-wide gate and
/// surfaces `ReadSetChanged`; schema apply has its own schema/table protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MutationOpKind {
Insert,

File diff suppressed because it is too large Load diff

View file

@ -21,8 +21,9 @@
//! retention. Destructive to version history — callers should gate this
//! behind an explicit confirm flag at the CLI layer.
//!
//! Both walk every node + edge table on the `main` branch. Run branches
//! are ephemeral by design so we do not optimize them.
//! Both orchestrate the graph's node + edge datasets from main authority;
//! cleanup preserves Lance-referenced named-branch history according to its
//! retention policy.
use std::time::Duration;
@ -113,10 +114,10 @@ pub struct TableOptimizeStats {
/// `fragments_removed == 0`, `fragments_added == 0`, and `!committed`.
pub skipped: Option<SkipReason>,
/// Manifest table version observed by optimize for drift skips. `None` for
/// normal compaction/no-op/blob skips.
/// normal compaction/no-op outcomes.
pub manifest_version: Option<u64>,
/// Lance HEAD version observed by optimize for drift skips. `None` for
/// normal compaction/no-op/blob skips.
/// normal compaction/no-op outcomes.
pub lance_head_version: Option<u64>,
/// Declared `@index` columns on this table the reconciler could not build
/// this run, each with the `reason` (today: a vector column with no
@ -141,20 +142,6 @@ impl TableOptimizeStats {
}
}
/// Stat for a table that was deliberately skipped (compaction not attempted).
fn skipped(table_key: String, reason: SkipReason) -> Self {
Self {
table_key,
fragments_removed: 0,
fragments_added: 0,
committed: false,
skipped: Some(reason),
manifest_version: None,
lance_head_version: None,
pending_indexes: Vec::new(),
}
}
/// Stat for a table skipped because the manifest and Lance HEAD disagree.
fn skipped_for_drift(
table_key: String,
@ -207,7 +194,6 @@ pub async fn optimize_all_tables(db: &Omnigraph) -> Result<Vec<TableOptimizeStat
recovery sweep before optimizing",
));
}
let snapshot = db.fresh_snapshot_for_branch(None).await?;
// Compute per-table paths up front, in a scope that drops the catalog
@ -402,8 +388,7 @@ async fn optimize_one_table(
// recovery and rolls back siblings).
let needs_reindex = TableStore::has_unindexed_fragments(&ds).await?;
let needs_index_create = if let Some(type_name) = table_key.strip_prefix("node:") {
super::table_ops::needs_index_work_node(db, type_name, &full_path, None)
.await?
super::table_ops::needs_index_work_node(db, type_name, &full_path, None).await?
} else {
super::table_ops::needs_index_work_edge(db, &full_path, None).await?
};
@ -450,7 +435,8 @@ async fn optimize_one_table(
}],
);
sidecar = Some(
crate::db::manifest::write_sidecar(db.root_uri(), db.storage_adapter(), &sc).await?,
crate::db::manifest::write_sidecar(db.root_uri(), db.storage_adapter(), &sc)
.await?,
);
}
@ -493,7 +479,8 @@ async fn optimize_one_table(
// committed (so HEAD is already ahead of the manifest from our own work),
// exercising the own-HEAD (not external) drift classification on the next
// reopened attempt.
if crate::failpoints::maybe_fail(crate::failpoints::names::OPTIMIZE_INJECT_REINDEX_CONFLICT).is_err()
if crate::failpoints::maybe_fail(crate::failpoints::names::OPTIMIZE_INJECT_REINDEX_CONFLICT)
.is_err()
&& attempt < COMPACTION_RETRY_BUDGET
{
continue;
@ -504,7 +491,10 @@ async fn optimize_one_table(
continue;
}
Err(e) => {
return Err(OmniError::Lance(format!("optimize_indices on {}: {}", table_key, e)));
return Err(OmniError::Lance(format!(
"optimize_indices on {}: {}",
table_key, e
)));
}
}
@ -528,7 +518,9 @@ async fn optimize_one_table(
// Pin the per-writer Phase B → Phase C residual: Lance HEAD has advanced but the
// manifest publish below hasn't run.
crate::failpoints::maybe_fail(crate::failpoints::names::OPTIMIZE_POST_PHASE_B_PRE_MANIFEST_COMMIT)?;
crate::failpoints::maybe_fail(
crate::failpoints::names::OPTIMIZE_POST_PHASE_B_PRE_MANIFEST_COMMIT,
)?;
// Phase C: monotonic fast-forward publish. The compaction is committed at Lance
// HEAD `N`; publish a manifest pointer that includes it. If a concurrent writer
@ -734,10 +726,7 @@ async fn compact_internal_table(
// conflict we re-open at the new HEAD and rerun — the canonical Lance-consumer
// pattern. Each attempt opens fresh because the conflict means the version moved.
for attempt in 0..COMPACTION_RETRY_BUDGET {
let handle = db
.storage()
.open_dataset_head(&uri, None)
.await?;
let handle = db.storage().open_dataset_head(&uri, None).await?;
let mut ds = handle.into_dataset();
// Keep optimize non-destructive by construction (see clear_stale_auto_cleanup_config).
@ -745,8 +734,7 @@ async fn compact_internal_table(
let cleared_config = match clear_stale_auto_cleanup_config(&mut ds).await {
Ok(cleared) => cleared,
Err(e) => {
if attempt + 1 < COMPACTION_RETRY_BUDGET && is_retryable_lance_conflict(&e)
{
if attempt + 1 < COMPACTION_RETRY_BUDGET && is_retryable_lance_conflict(&e) {
continue;
}
return Err(OmniError::Lance(e.to_string()));
@ -784,10 +772,7 @@ async fn compact_internal_table(
true,
));
}
Err(e)
if attempt + 1 < COMPACTION_RETRY_BUDGET
&& is_retryable_lance_conflict(&e) =>
{
Err(e) if attempt + 1 < COMPACTION_RETRY_BUDGET && is_retryable_lance_conflict(&e) => {
continue;
}
Err(e) => return Err(OmniError::Lance(e.to_string())),
@ -815,11 +800,46 @@ pub async fn cleanup_all_tables(
db.ensure_schema_state_valid().await?;
db.ensure_schema_apply_idle("cleanup").await?;
// Version GC must never run while recovery still needs exact Lance
// transaction/version history to prove effect ownership or resume an
// interrupted compensation. Refuse before orphan reconciliation or any
// per-table cleanup so this operation is all-or-nothing with respect to the
// recovery-history floor. A read-write reopen resolves the sidecar first.
if !crate::db::manifest::list_sidecars(db.root_uri(), db.storage_adapter())
.await?
.is_empty()
{
return Err(OmniError::manifest_conflict(
"cleanup requires a clean recovery state; reopen the graph to run the \
recovery sweep before garbage-collecting versions",
));
}
crate::failpoints::maybe_fail(
crate::failpoints::names::CLEANUP_POST_RECOVERY_CHECK_PRE_GATES,
)?;
// Close the empty-check -> GC race. Mutation/load take schema then branch
// then table gates; current legacy sidecar writers take at least their table
// gates. Cleanup takes the conservative superset and holds it through every
// `cleanup_old_versions` call, then performs the authoritative sidecar check
// under those gates. Without this envelope a writer can arm+commit+fail after
// the fast check and GC can delete the exact transaction/version history
// Full recovery needs to prove ownership or Restore.
let _cleanup_schema_guard = db
.write_queue()
.acquire(&crate::db::manifest::schema_apply_serial_queue_key())
.await;
db.refresh_coordinator_only().await?;
db.ensure_schema_apply_not_locked("cleanup").await?;
let cleanup_catalog = db
.load_accepted_catalog_with_schema_gate_held()
.await?;
// Reclaim orphaned branch forks (from an incomplete prior `branch_delete`)
// before version GC. Authority-derived and idempotent; the eager
// best-effort reclaim in `branch_delete` covers the common case, this is
// the guaranteed backstop. Logged for observability.
let reconciled = reconcile_orphaned_branches(db).await?;
let reconciled = reconcile_orphaned_branches_with_catalog(db, &cleanup_catalog).await?;
if !reconciled.reclaimed.is_empty() {
tracing::info!(
count = reconciled.reclaimed.len(),
@ -835,13 +855,10 @@ pub async fn cleanup_all_tables(
);
}
let before_timestamp = options.older_than.map(|d| Utc::now() - d);
let keep_versions = options.keep_versions;
let resolved = db.resolved_branch_target(None).await?;
let snapshot = resolved.snapshot;
let table_tasks: Vec<_> = all_table_keys(&db.catalog())
let table_tasks: Vec<_> = all_table_keys(&cleanup_catalog)
.into_iter()
.filter_map(|table_key| {
let entry = snapshot.entry(&table_key)?;
@ -850,6 +867,38 @@ pub async fn cleanup_all_tables(
})
.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::<Vec<_>>();
graph_branches.push(None);
graph_branches.sort();
graph_branches.dedup();
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;
if !crate::db::manifest::list_sidecars(db.root_uri(), db.storage_adapter())
.await?
.is_empty()
{
return Err(OmniError::manifest_conflict(
"cleanup observed a recovery sidecar after acquiring its GC gates; reopen the graph \
read-write to recover before garbage-collecting versions",
));
}
let before_timestamp = options.older_than.map(|d| Utc::now() - d);
let keep_versions = options.keep_versions;
if table_tasks.is_empty() {
return Ok(Vec::new());
}
@ -868,9 +917,7 @@ pub async fn cleanup_all_tables(
// `cleanup_old_versions` is a Lance-only maintenance API not
// surfaced through `TableStorage` — see the optimize path
// above for the same rationale. Unwrap via `into_dataset()`.
let handle = storage
.open_dataset_head(&full_path, None)
.await?;
let handle = storage.open_dataset_head(&full_path, None).await?;
let ds = handle.into_dataset();
let before_version = keep_versions
.map(|n| ds.version().version.saturating_sub(n as u64))
@ -928,8 +975,9 @@ pub struct BranchReconcileStats {
pub failures: Vec<(String, String)>,
}
/// Drop every per-table and commit-graph Lance branch fork the manifest does
/// not reference.
/// 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
@ -953,7 +1001,16 @@ pub struct BranchReconcileStats {
/// are dropped before parents (longest name first). Idempotent and authority-
/// derived: no-ops once reconciled, and degrades to finding nothing if a future
/// Lance atomic multi-dataset branch op prevents orphans from forming.
#[cfg(all(test, feature = "failpoints"))]
pub async fn reconcile_orphaned_branches(db: &Omnigraph) -> Result<BranchReconcileStats> {
let catalog = db.catalog();
reconcile_orphaned_branches_with_catalog(db, &catalog).await
}
async fn reconcile_orphaned_branches_with_catalog(
db: &Omnigraph,
catalog: &omnigraph_compiler::catalog::Catalog,
) -> Result<BranchReconcileStats> {
use std::collections::{HashMap, HashSet};
// Live manifest branches: the set whose per-table placements are
@ -969,7 +1026,7 @@ pub async fn reconcile_orphaned_branches(db: &Omnigraph) -> Result<BranchReconci
let resolved = db.resolved_branch_target(None).await?;
let snapshot = resolved.snapshot;
let table_targets: Vec<(String, String)> = all_table_keys(&db.catalog())
let table_targets: Vec<(String, String)> = all_table_keys(catalog)
.into_iter()
.filter_map(|table_key| {
let entry = snapshot.entry(&table_key)?;
@ -1021,11 +1078,12 @@ pub async fn reconcile_orphaned_branches(db: &Omnigraph) -> Result<BranchReconci
continue;
}
if !branch_snapshots.contains_key(&branch) {
let branch_snapshot =
match crate::failpoints::maybe_fail(crate::failpoints::names::CLEANUP_RESOLVE_BRANCH_SNAPSHOT) {
Ok(()) => db.snapshot_for_branch(Some(&branch)).await,
Err(injected) => Err(injected),
};
let branch_snapshot = match crate::failpoints::maybe_fail(
crate::failpoints::names::CLEANUP_RESOLVE_BRANCH_SNAPSHOT,
) {
Ok(()) => db.snapshot_for_branch(Some(&branch)).await,
Err(injected) => Err(injected),
};
match branch_snapshot {
Ok(snap) => {
branch_snapshots.insert(branch.clone(), snap);
@ -1083,7 +1141,7 @@ pub async fn reconcile_orphaned_branches(db: &Omnigraph) -> Result<BranchReconci
// skipped and recorded. (Cross-process writers remain the documented
// one-winner-CAS gap.) One key held at a time → no lock-order
// inversion vs multi-table `acquire_many` writers.
match super::table_ops::classify_fork_ref(db, &table_key, &branch).await {
match super::table_ops::classify_fork_ref(db, &table_key, &branch, None).await {
super::table_ops::ForkRefStatus::Orphan => {}
super::table_ops::ForkRefStatus::Legitimate => continue,
super::table_ops::ForkRefStatus::Indeterminate => {
@ -1101,7 +1159,9 @@ pub async fn reconcile_orphaned_branches(db: &Omnigraph) -> Result<BranchReconci
continue;
}
}
let outcome = match crate::failpoints::maybe_fail(crate::failpoints::names::CLEANUP_RECONCILE_FORK) {
let outcome = match crate::failpoints::maybe_fail(
crate::failpoints::names::CLEANUP_RECONCILE_FORK,
) {
Ok(()) => storage.force_delete_branch(&full_path, &branch).await,
Err(injected) => Err(injected),
};

View file

@ -152,6 +152,15 @@ where
// exists, so the heal can never observe it.
db.heal_pending_recovery_sidecars().await?;
// Process-local schema-control gate. RFC-022 mutation/load commit paths
// acquire this before their branch/table gates and retain it through
// publication. Taking it before the durable sentinel closes the old race in
// which schema apply could create the sentinel while a mutation already held
// a table queue, causing that mutation to advance Lance HEAD and only then
// discover the schema lock. The native sentinel remains the cross-handle /
// crash-visible authority; this queue removes the avoidable same-handle race.
let schema_gate_key = crate::db::manifest::schema_apply_serial_queue_key();
let _schema_gate = db.write_queue().acquire(&schema_gate_key).await;
acquire_schema_apply_lock(db).await?;
let result = apply_schema_with_lock(db, desired_schema_source, options, validate_catalog).await;
let release_result = release_schema_apply_lock(db).await;
@ -440,23 +449,17 @@ where
// practice. They exist for symmetry with the recovery reconciler, which
// acquires the same queues before any `Dataset::restore` it issues for
// SchemaApply sidecars.
let mut schema_apply_queue_keys: Vec<(String, Option<String>)> = recovery_pins
let schema_apply_queue_keys: Vec<(String, Option<String>)> = recovery_pins
.iter()
.map(|pin| (pin.table_key.clone(), pin.table_branch.clone()))
.collect();
// The serialization key the write-entry heal acquires before touching
// schema staging or a SchemaApply sidecar. Per-table keys alone don't
// cover a registration-only migration (no pins, but a sidecar and
// staging files on disk) — without this, a concurrent write's heal can
// promote this apply's staging files and publish its registrations out
// from under it. Acquired whenever a sidecar will be written, held
// through Phase D (the guards live to the end of this function).
// The outer `apply_schema` holds the schema-control serialization key from
// before sentinel creation through sentinel release. Per-table guards here
// therefore cover only the concrete table effects; acquiring the schema key
// again would deadlock because these queues are intentionally non-reentrant.
let writes_sidecar = !(recovery_pins.is_empty()
&& sidecar_registrations.is_empty()
&& sidecar_tombstones.is_empty());
if writes_sidecar {
schema_apply_queue_keys.push(crate::db::manifest::schema_apply_serial_queue_key());
}
let _schema_apply_queue_guards = db
.write_queue()
.acquire_many(&schema_apply_queue_keys)
@ -862,10 +865,7 @@ pub(super) async fn ensure_snapshot_entry_head_matches(
let dataset_uri = db.storage().dataset_uri(&entry.table_path);
let ds = db
.storage()
.open_dataset_head(
&dataset_uri,
entry.table_branch.as_deref(),
)
.open_dataset_head(&dataset_uri, entry.table_branch.as_deref())
.await?;
db.storage()
.ensure_expected_version(&ds, &entry.table_key, entry.table_version)

View file

@ -118,8 +118,7 @@ pub(super) async fn ensure_indices_for_branch(
continue;
}
let full_path = format!("{}/{}", db.root_uri, entry.table_path);
if needs_index_work_node(db, type_name, &full_path, entry.table_branch.as_deref()).await?
{
if needs_index_work_node(db, type_name, &full_path, entry.table_branch.as_deref()).await? {
recovery_pins.push(crate::db::manifest::SidecarTablePin {
table_key,
table_path: full_path,
@ -213,14 +212,13 @@ pub(super) async fn ensure_indices_for_branch(
entry.table_version,
active_branch,
crate::db::MutationOpKind::SchemaRewrite,
false,
)
.await?
}
},
None => (
db.storage()
.open_dataset_head(&full_path, None)
.await?,
db.storage().open_dataset_head(&full_path, None).await?,
None,
),
};
@ -261,14 +259,13 @@ pub(super) async fn ensure_indices_for_branch(
entry.table_version,
active_branch,
crate::db::MutationOpKind::SchemaRewrite,
false,
)
.await?
}
},
None => (
db.storage()
.open_dataset_head(&full_path, None)
.await?,
db.storage().open_dataset_head(&full_path, None).await?,
None,
),
};
@ -296,7 +293,9 @@ pub(super) async fn ensure_indices_for_branch(
// (one commit_staged per index built) but the manifest publish below
// hasn't run. Used by
// `tests/failpoints.rs::ensure_indices_phase_b_failure_recovered_on_next_open`.
crate::failpoints::maybe_fail(crate::failpoints::names::ENSURE_INDICES_POST_PHASE_B_PRE_MANIFEST_COMMIT)?;
crate::failpoints::maybe_fail(
crate::failpoints::names::ENSURE_INDICES_POST_PHASE_B_PRE_MANIFEST_COMMIT,
)?;
if !updates.is_empty() {
commit_prepared_updates_on_branch(db, branch, &updates, None).await?;
@ -500,6 +499,16 @@ pub(crate) struct OpenedForMutation {
pub(crate) expected_version: u64,
pub(crate) full_path: String,
pub(crate) table_branch: Option<String>,
/// RFC-022 first-touch named-branch writes stage against the inherited
/// source snapshot and defer the durable Lance ref creation until after
/// their v3 recovery intent is armed in `StagedMutation::commit_all`.
pub(crate) deferred_fork: Option<DeferredTableFork>,
}
#[derive(Debug, Clone)]
pub(crate) struct DeferredTableFork {
pub(crate) source_entry: crate::db::SubTableEntry,
pub(crate) target_branch: String,
}
impl OpenedForMutation {
@ -590,6 +599,7 @@ pub(super) async fn open_for_mutation_on_branch(
expected_version: entry.table_version,
full_path,
table_branch: Some(active_branch.to_string()),
deferred_fork: None,
});
}
// Main branch, non-strict → no open. (Main never forks.)
@ -599,6 +609,7 @@ pub(super) async fn open_for_mutation_on_branch(
expected_version: entry.table_version,
full_path,
table_branch: None,
deferred_fork: None,
});
}
// Non-strict but the table isn't on the active branch yet — falls
@ -609,13 +620,19 @@ pub(super) async fn open_for_mutation_on_branch(
match resolved_branch.as_deref() {
None => {
let ds = db
.storage()
.open_dataset_head(&full_path, None)
.await?;
let ds = db.storage().open_dataset_head(&full_path, None).await?;
if op_kind.strict_pre_stage_version_check() {
db.storage()
.ensure_expected_version(&ds, table_key, entry.table_version)?;
if txn.is_some() && ds.version() != entry.table_version {
return Err(OmniError::manifest_read_set_changed(
format!("table_head:{table_key}"),
Some(entry.table_version.to_string()),
Some(ds.version().to_string()),
));
}
if txn.is_none() {
db.storage()
.ensure_expected_version(&ds, table_key, entry.table_version)?;
}
}
let version = ds.version();
Ok(OpenedForMutation {
@ -623,9 +640,28 @@ pub(super) async fn open_for_mutation_on_branch(
expected_version: version,
full_path,
table_branch: None,
deferred_fork: None,
})
}
Some(active_branch) => {
// RFC-022-enrolled mutation/load adapters must arm durable intent
// before creating a per-table Lance branch ref. Read and stage from
// the inherited source entry now; `commit_all` creates the target
// ref after its v3 sidecar is durable, then commits this transaction
// onto the new ref. Legacy writers retain the eager fork path below.
if txn.is_some() && entry.table_branch.as_deref() != Some(active_branch) {
let ds = db.storage().open_snapshot_at_entry(entry).await?;
return Ok(OpenedForMutation {
handle: Some(ds),
expected_version: entry.table_version,
full_path,
table_branch: Some(active_branch.to_string()),
deferred_fork: Some(DeferredTableFork {
source_entry: entry.clone(),
target_branch: active_branch.to_string(),
}),
});
}
let (ds, table_branch) = open_owned_dataset_for_branch_write(
db,
table_key,
@ -634,6 +670,7 @@ pub(super) async fn open_for_mutation_on_branch(
entry.table_version,
active_branch,
op_kind,
txn.is_some(),
)
.await?;
let version = ds.version();
@ -642,6 +679,7 @@ pub(super) async fn open_for_mutation_on_branch(
expected_version: version,
full_path,
table_branch,
deferred_fork: None,
})
}
}
@ -655,6 +693,7 @@ pub(super) async fn open_owned_dataset_for_branch_write(
entry_version: u64,
active_branch: &str,
op_kind: crate::db::MutationOpKind,
occ_enrolled: bool,
) -> Result<(SnapshotHandle, Option<String>)> {
match entry_branch {
Some(branch) if branch == active_branch => {
@ -663,8 +702,17 @@ pub(super) async fn open_owned_dataset_for_branch_write(
.open_dataset_head(full_path, Some(active_branch))
.await?;
if op_kind.strict_pre_stage_version_check() {
db.storage()
.ensure_expected_version(&ds, table_key, entry_version)?;
if occ_enrolled && ds.version() != entry_version {
return Err(OmniError::manifest_read_set_changed(
format!("table_head:{table_key}"),
Some(entry_version.to_string()),
Some(ds.version().to_string()),
));
}
if !occ_enrolled {
db.storage()
.ensure_expected_version(&ds, table_key, entry_version)?;
}
}
Ok((ds, Some(active_branch.to_string())))
}
@ -678,11 +726,19 @@ pub(super) async fn open_owned_dataset_for_branch_write(
let live = db.snapshot_for_branch(Some(active_branch)).await?;
if let Some(entry) = live.entry(table_key) {
if entry.table_branch.as_deref() == Some(active_branch) {
return Err(OmniError::manifest_expected_version_mismatch(
table_key,
entry_version,
entry.table_version,
));
return if occ_enrolled {
Err(OmniError::manifest_read_set_changed(
format!("table_head:{table_key}"),
Some(entry_version.to_string()),
Some(entry.table_version.to_string()),
))
} else {
Err(OmniError::manifest_expected_version_mismatch(
table_key,
entry_version,
entry.table_version,
))
};
}
}
// The fork advances Lance state before the manifest publish. The
@ -760,7 +816,32 @@ pub(crate) async fn classify_fork_ref(
db: &Omnigraph,
table_key: &str,
branch: &str,
excluding_operation_id: Option<&str>,
) -> ForkRefStatus {
// Deferred mutation/load forks are created only after their v3 sidecar is
// durable. Until the manifest publish places this table on `branch`, that
// sidecar is the only durable ownership record for the ref. Treat a
// matching pending intent as indeterminate rather than an orphan so neither
// destructive caller can steal a live writer's fork. The writer that owns
// the intent may exclude itself while reclaiming a genuinely stale ref it
// collided with; every other sidecar remains a hard stop. A list failure is
// likewise indeterminate -- cleanup must never turn missing authority into
// permission to delete.
let sidecars =
match crate::db::manifest::list_sidecars(db.root_uri(), db.storage_adapter()).await {
Ok(sidecars) => sidecars,
Err(_) => return ForkRefStatus::Indeterminate,
};
if sidecars.iter().any(|sidecar| {
Some(sidecar.operation_id.as_str()) != excluding_operation_id
&& sidecar.branch.as_deref() == Some(branch)
&& sidecar.tables.iter().any(|pin| {
pin.table_key == table_key && pin.table_branch.as_deref() == Some(branch)
})
}) {
return ForkRefStatus::Indeterminate;
}
// `classify.fresh_read` failpoint: simulate a transient failure of the
// fresh-authority read (no-op without the `failpoints` feature). Lets a
// test exercise the Indeterminate path — a read failure on a live branch
@ -819,13 +900,34 @@ pub(super) async fn reclaim_orphaned_fork_and_refork(
source_branch: Option<&str>,
source_version: u64,
active_branch: &str,
current_operation_id: Option<&str>,
) -> Result<SnapshotHandle> {
// A v3 mutation/load sidecar 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.
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.branch.as_deref() == Some(active_branch)
&& sidecar.tables.iter().any(|pin| {
pin.table_key == table_key && 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, active_branch).await {
match classify_fork_ref(db, table_key, active_branch, current_operation_id).await {
ForkRefStatus::Orphan => {}
ForkRefStatus::Legitimate => {
let actual = db
@ -834,6 +936,13 @@ pub(super) async fn reclaim_orphaned_fork_and_refork(
.ok()
.and_then(|s| s.entry(table_key).map(|e| e.table_version))
.unwrap_or(source_version);
if current_operation_id.is_some() {
return Err(OmniError::manifest_read_set_changed(
format!("table_head:{table_key}"),
Some(source_version.to_string()),
Some(actual.to_string()),
));
}
return Err(OmniError::manifest_expected_version_mismatch(
table_key,
source_version,
@ -1101,7 +1210,9 @@ async fn stage_and_commit_btree(
// to demonstrate that a stage-step failure in the staged-index
// path (`stage_create_btree_index` succeeded; `commit_staged` not
// yet called) leaves no Lance-HEAD drift on the touched table.
crate::failpoints::maybe_fail(crate::failpoints::names::ENSURE_INDICES_POST_STAGE_PRE_COMMIT_BTREE)?;
crate::failpoints::maybe_fail(
crate::failpoints::names::ENSURE_INDICES_POST_STAGE_PRE_COMMIT_BTREE,
)?;
let new_ds = db
.storage()
.commit_staged(ds.clone(), staged)
@ -1153,30 +1264,28 @@ async fn prepare_updates_for_commit(
branch: Option<&str>,
updates: &[crate::db::SubTableUpdate],
txn: Option<&crate::db::WriteTxn>,
// Post-`commit_staged` handles handed out by `StagedMutation::commit_all`
// (RFC-013 step 3b, collapse #4): table_key → the handle already open at
// its just-committed version. When a table's handle is present, the index
// build below reuses it and SKIPS the `reopen_for_mutation` open. Absent
// entries (other writers — schema apply, merge, ensure_indices, tests —
// pass `HashMap::new()`) keep the byte-identical `reopen_for_mutation`
// path. Delete tables ARE staged now (MR-A), so their handle is present
// like any other staged write.
mut committed_handles: std::collections::HashMap<String, SnapshotHandle>,
) -> Result<Vec<crate::db::SubTableUpdate>> {
if updates.is_empty() {
return Ok(Vec::new());
}
// With a `WriteTxn` the schema contract was validated once at capture, so
// reuse the pinned base entries (same per-branch manifest snapshot) instead
// of `snapshot_for_branch` (which re-runs `ensure_schema_state_valid`). Only
// the `entry(table_key).table_path` is read out of it here, identical to the
// no-txn path; the post-`commit_staged` index build below still reopens the
// dataset at its just-committed version. Without a txn, byte-identical.
let snapshot = match txn {
Some(txn) => txn.base.clone(),
None => db.snapshot_for_branch(branch).await?,
};
// RFC-022 mutation/load adapter: the physical effect envelope must be
// closed before its recovery sidecar is armed. Building indexes here can
// add one or several extra Lance commits (and vector-index creation is an
// inline-commit residual), so those commits cannot be represented as the
// staged data transaction promised by the sidecar. Indexes are derived
// state: enrolled mutation/load writes publish the exact data-table result
// and leave declared-index materialization to the existing
// ensure_indices/optimize reconciler. Other writers keep their historical
// behavior until their own effect adapters migrate.
if txn.is_some() {
return Ok(updates.to_vec());
}
// Enrolled mutation/load returned above: derived-index work is deliberately
// outside their exact physical effect envelope. Legacy callers retain the
// historical reopen/build path.
let snapshot = db.snapshot_for_branch(branch).await?;
let mut prepared = Vec::with_capacity(updates.len());
for update in updates {
@ -1190,38 +1299,26 @@ async fn prepare_updates_for_commit(
let mut prepared_update = update.clone();
if prepared_update.row_count > 0 {
let full_path = format!("{}/{}", db.root_uri, entry.table_path);
// Reuse the post-`commit_staged` handle when the caller handed one
// out (collapse #4): it is already open at exactly
// `prepared_update.table_version`, so the defense-in-depth strict
// re-check `reopen_for_mutation` would run is trivially satisfied
// and the open is redundant. When no handle is present (other
// writers, or any non-staged table), fall back to the byte-identical
// `reopen_for_mutation` path.
//
// Strict version check is correct on the fallback: this runs INSIDE
// Strict version check is correct here: this runs INSIDE
// the publisher commit path, after `commit_staged` already
// advanced Lance HEAD to `prepared_update.table_version`.
// The check is a defense-in-depth assertion that the
// dataset state matches what we just committed; not the
// pre-stage race the op-kind policy targets.
let mut ds = match committed_handles.remove(&prepared_update.table_key) {
Some(ds) => ds,
None => {
reopen_for_mutation(
db,
&prepared_update.table_key,
&full_path,
prepared_update.table_branch.as_deref(),
prepared_update.table_version,
crate::db::MutationOpKind::SchemaRewrite,
)
.await?
}
};
let mut ds = reopen_for_mutation(
db,
&prepared_update.table_key,
&full_path,
prepared_update.table_branch.as_deref(),
prepared_update.table_version,
crate::db::MutationOpKind::SchemaRewrite,
)
.await?;
// Any column not yet buildable (e.g. a vector column whose rows
// have null embeddings) is deferred and logged inside
// build_indices; a later ensure_indices/optimize materializes it.
// The load/mutate/merge commit must not fail on it.
// Legacy merge/test callers must not fail on it; enrolled
// mutation/load callers returned before this block.
let _pending =
build_indices_on_dataset(db, &prepared_update.table_key, &mut ds).await?;
let state = db.storage().table_state(&full_path, &ds).await?;
@ -1253,6 +1350,7 @@ async fn commit_prepared_updates(
Ok(manifest_version)
}
#[cfg(feature = "failpoints")]
async fn commit_prepared_updates_with_expected(
db: &Omnigraph,
updates: &[crate::db::SubTableUpdate],
@ -1303,6 +1401,7 @@ pub(super) async fn commit_prepared_updates_on_branch(
Ok(manifest_version)
}
#[cfg(feature = "failpoints")]
pub(super) async fn commit_prepared_updates_on_branch_with_expected(
db: &Omnigraph,
branch: Option<&str>,
@ -1356,14 +1455,8 @@ pub(super) async fn commit_updates(
.await
.current_branch()
.map(str::to_string);
let prepared = prepare_updates_for_commit(
db,
current_branch.as_deref(),
updates,
None,
std::collections::HashMap::new(),
)
.await?;
let prepared =
prepare_updates_for_commit(db, current_branch.as_deref(), updates, None).await?;
commit_prepared_updates(db, &prepared, None).await
}
@ -1390,20 +1483,60 @@ pub(super) async fn commit_updates_on_branch_with_expected(
updates: &[crate::db::SubTableUpdate],
expected_table_versions: &std::collections::HashMap<String, u64>,
actor_id: Option<&str>,
txn: Option<&crate::db::WriteTxn>,
committed_handles: std::collections::HashMap<String, SnapshotHandle>,
txn: &crate::db::WriteTxn,
lineage_intent: crate::db::manifest::LineageIntent,
) -> Result<u64> {
db.ensure_schema_apply_not_locked("write commit").await?;
let prepared =
prepare_updates_for_commit(db, branch, updates, txn, committed_handles).await?;
commit_prepared_updates_on_branch_with_expected(
db,
let prepared = prepare_updates_for_commit(db, branch, updates, Some(txn)).await?;
debug_assert_eq!(lineage_intent.actor_id.as_deref(), actor_id);
let changes = prepared
.iter()
.cloned()
.map(ManifestChange::Update)
.collect::<Vec<_>>();
let expectation = crate::db::manifest::GraphHeadExpectation::new(
branch,
&prepared,
expected_table_versions,
actor_id,
)
.await
txn.authority.branch_identifier.clone(),
txn.authority.graph_head.clone(),
);
let precondition = crate::db::manifest::PublishPrecondition::ExactGraphHead(expectation);
let current_branch = db
.coordinator
.read()
.await
.current_branch()
.map(str::to_string);
let requested_branch = branch.map(str::to_string);
let published = if requested_branch == current_branch {
db.coordinator
.write()
.await
.commit_changes_with_intent_and_expected(
&changes,
expected_table_versions,
lineage_intent,
&precondition,
)
.await?
} else {
let mut coordinator = match requested_branch.as_deref() {
Some(branch) => {
GraphCoordinator::open_branch(db.uri(), branch, Arc::clone(&db.storage)).await?
}
None => GraphCoordinator::open(db.uri(), Arc::clone(&db.storage)).await?,
};
coordinator
.commit_changes_with_intent_and_expected(
&changes,
expected_table_versions,
lineage_intent,
&precondition,
)
.await?
};
Ok(published.manifest_version)
}
pub(super) async fn invalidate_graph_index(db: &Omnigraph) {
@ -1452,7 +1585,7 @@ mod classify_fork_ref_tests {
.await
.unwrap();
assert_eq!(
classify_fork_ref(&db, "node:Company", "feature").await,
classify_fork_ref(&db, "node:Company", "feature", None).await,
ForkRefStatus::Legitimate,
"a manifest-placed fork must classify as Legitimate (never destroyed)"
);
@ -1467,7 +1600,7 @@ mod classify_fork_ref_tests {
ds.create_branch("feature", v, None).await.unwrap();
}
assert_eq!(
classify_fork_ref(&db, "node:Person", "feature").await,
classify_fork_ref(&db, "node:Person", "feature", None).await,
ForkRefStatus::Orphan,
"a ref the manifest does not place on the branch must classify as Orphan"
);
@ -1480,7 +1613,7 @@ mod classify_fork_ref_tests {
ds.create_branch("ghost", v, None).await.unwrap();
}
assert_eq!(
classify_fork_ref(&db, "node:Person", "ghost").await,
classify_fork_ref(&db, "node:Person", "ghost", None).await,
ForkRefStatus::Orphan,
"a ref for a branch absent from the manifest must classify as Orphan"
);

View file

@ -1,10 +1,11 @@
//! Recovery audit row storage in `_graph_commit_recoveries.lance`.
//!
//! A standalone internal table (not catalog-tracked). Each successful
//! recovery sweep — roll-forward or roll-back — records one row here so
//! operators investigating a sidecar-attributed mutation can correlate
//! `omnigraph commit list --filter actor=omnigraph:recovery` with the
//! original actor whose mutation was rolled forward / back.
//! A standalone internal table (not catalog-tracked). Each completed recovery
//! action records one row here with the original actor and exact per-table
//! outcome. A v3 roll-forward preserves the interrupted writer's lineage and
//! actor, while rollback and legacy recovery lineage use
//! `omnigraph:recovery`; ordinary commit history is therefore not a complete
//! recovery log. This table currently has no public CLI query surface.
//!
//! This standalone table is additive: it doesn't bump
//! `INTERNAL_MANIFEST_SCHEMA_VERSION`. Folding `recovery_for_actor` and
@ -14,10 +15,10 @@
//! Atomicity caveat: append to `_graph_commit_recoveries.lance` is
//! sequential w.r.t. the recovery commit, which RFC-013 Phase 7 records in
//! `__manifest` (folded into the recovery publish CAS via `publish_recovery_commit`).
//! A crash between the publish and this audit append leaves a recovery commit
//! with no audit row. The recovery sweep tolerates it the same way (re-entry
//! sees `NoMovement` for already-restored / already-published tables; the audit
//! append is retried, minting a fresh recovery commit).
//! A crash between the publish and this audit append leaves a visible outcome
//! with no audit row. V3 sidecars carry fixed outcome ids and durable audit
//! payloads, so re-entry appends the missing row without minting another
//! commit; legacy sidecars retain their stale-sidecar cleanup behavior.
use std::sync::Arc;

View file

@ -78,7 +78,21 @@ pub(crate) async fn load_or_bootstrap_schema_contract(
pub(crate) async fn validate_schema_contract(
root_uri: &str,
storage: Arc<dyn StorageAdapter>,
) -> Result<()> {
) -> Result<SchemaState> {
load_validated_schema_contract(root_uri, storage)
.await
.map(|(_, state)| state)
}
/// Load the accepted IR and its schema identity from one validated contract
/// read. Mutation/load preparation carries the catalog built from this exact IR
/// beside the identity in its `WriteTxn`; consulting the handle-global catalog
/// would let a long-lived handle combine a newly observed schema token with an
/// older in-memory plan.
pub(crate) async fn load_validated_schema_contract(
root_uri: &str,
storage: Arc<dyn StorageAdapter>,
) -> Result<(SchemaIR, SchemaState)> {
let current_source_ir = read_current_source_ir(root_uri, storage.as_ref()).await?;
let (persisted_ir, state) = match read_schema_contract(root_uri, storage.as_ref()).await? {
SchemaContractRead::Present { ir, state } => (ir, state),
@ -90,7 +104,33 @@ pub(crate) async fn validate_schema_contract(
};
validate_persisted_schema_contract(&persisted_ir, &state)?;
validate_current_source_matches(&state, &current_source_ir)
validate_current_source_matches(&state, &current_source_ir)?;
Ok((persisted_ir, state))
}
/// Read only the durable schema-identity marker. Schema apply promotes this
/// file after `_schema.pg` and `_schema.ir.json`, then releases its sentinel.
/// A capture path that already performed one full contract validation can use a
/// trailing marker read to detect the publish-before-promotion window without
/// paying for a second full source+IR parse.
pub(crate) async fn read_schema_state_identity(
root_uri: &str,
storage: &dyn StorageAdapter,
) -> Result<SchemaState> {
let text = storage.read_text(&schema_state_uri(root_uri)).await?;
let state = serde_json::from_str::<SchemaState>(&text).map_err(|err| {
schema_lock_conflict(format!(
"graph schema state in {} is invalid: {}",
SCHEMA_STATE_FILENAME, err
))
})?;
if state.format_version != SCHEMA_STATE_FORMAT_VERSION {
return Err(schema_lock_conflict(format!(
"graph schema state format {} is unsupported",
state.format_version
)));
}
Ok(state)
}
pub(crate) async fn write_schema_contract(

View file

@ -1,15 +1,18 @@
//! Per-`(table_key, branch)` writer queues.
//!
//! These queues are the engine's write-serialization mechanism: the server
//! holds the engine as a lockless `Arc<Omnigraph>` (writes are `&self`), so
//! disjoint-key writes proceed concurrently and only writes to the same
//! `(table_key, branch_ref)` serialize here. This module owns the queue
//! data structure; callers in `MutationStaging::commit_all`, `branch_merge`,
//! `schema_apply`, `ensure_indices`, the fork path (first write to a table on
//! a branch — acquired before the fork, held through the manifest publish),
//! and the recovery reconciler acquire guards before any per-table Lance
//! commit. Serialization is in-process only; cross-process
//! writers on one graph remain one-winner-CAS at the manifest publish.
//! These queues are the engine's process-local, root-scoped write-serialization
//! mechanism. The server normally holds one lockless `Arc<Omnigraph>`, but
//! independently opened handles for the same canonical local root identity
//! (or the same opaque object-store URI) share this manager too. Legacy
//! writers serialize only on `(table_key, branch_ref)` so
//! disjoint keys can proceed concurrently. RFC-022-enrolled mutation/load
//! attempts additionally take a coarse branch effect gate because validation
//! may depend on tables they do not write. This module owns both queue classes;
//! callers in `MutationStaging::commit_all`, branch controls, `branch_merge`,
//! `schema_apply`, `ensure_indices`, cleanup, branch forking, and recovery acquire the applicable guards
//! before a Lance HEAD advance or destructive recovery action. Serialization
//! remains in-process only; cross-process writers on one graph remain
//! one-winner-CAS at publish.
//!
//! ## Why exclusive `tokio::sync::Mutex<()>` per key
//!
@ -32,13 +35,13 @@
//! ## Sorted-order acquisition
//!
//! `acquire_many` accepts a slice of keys and acquires them in
//! lexicographic order. Multi-table writers (mutation finalize,
//! branch_merge, future recovery reconciler) MUST go through
//! lexicographic order. Multi-table writers and control paths (mutation
//! finalize, branch merge, schema apply, maintenance, and recovery) MUST go through
//! `acquire_many` so all callers agree on acquisition order — this is
//! how lock-order inversion deadlock is prevented.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
@ -52,14 +55,26 @@ pub(crate) type TableQueueKey = (String, Option<String>);
/// Per-`(table_key, branch)` writer queue manager.
///
/// Lives on `Omnigraph` as `Arc<WriteQueueManager>` so HTTP handlers,
/// engine internals, the CLI binary, and future background reconcilers
/// (MR-870 recovery, MR-848 index) all reach it via the engine handle.
/// Every `Omnigraph` handle for one canonical root identity shares the same
/// manager via a process-global weak registry. This matters beyond HTTP's usual
/// `Arc<Omnigraph>` shape: a separately-opened handle can run recovery, and
/// Lance Restore/ref deletion must serialize with a live writer owned by the
/// first handle. The registry deliberately keys only by the queue root
/// identity; custom storage adapters for the same URI conservatively serialize
/// too.
#[derive(Default)]
pub(crate) struct WriteQueueManager {
/// Held only briefly per `acquire` call: clone out the per-key Arc,
/// release the std mutex, then await the per-key tokio Mutex.
queues: Mutex<HashMap<TableQueueKey, Arc<AsyncMutex<()>>>>,
/// Coarse per-branch effect gate used by RFC-022-enrolled writers.
///
/// This is deliberately separate from `queues`: a branch is authority,
/// not a synthetic table key. Enrolled writers acquire this gate before
/// any table queue and hold it through manifest publication. Legacy
/// writers continue to use only the table queues until their adapters are
/// migrated.
branch_queues: Mutex<HashMap<Option<String>, Arc<AsyncMutex<()>>>>,
}
impl WriteQueueManager {
@ -67,6 +82,24 @@ impl WriteQueueManager {
Self::default()
}
/// Return the process-wide queue manager for one canonical graph-root
/// identity.
/// Weak values avoid retaining every graph URI ever opened by a long-lived
/// multi-tenant process; lookup opportunistically removes dead entries.
pub(crate) fn for_root(root_identity: &str) -> Arc<Self> {
static REGISTRY: OnceLock<Mutex<HashMap<String, Weak<WriteQueueManager>>>> =
OnceLock::new();
let registry = REGISTRY.get_or_init(|| Mutex::new(HashMap::new()));
let mut roots = registry.lock().expect("root write queue registry poisoned");
if let Some(existing) = roots.get(root_identity).and_then(Weak::upgrade) {
return existing;
}
roots.retain(|_, manager| manager.strong_count() > 0);
let manager = Arc::new(Self::new());
roots.insert(root_identity.to_string(), Arc::downgrade(&manager));
manager
}
/// Get-or-create the per-key queue and clone its Arc.
fn slot(&self, key: &TableQueueKey) -> Arc<AsyncMutex<()>> {
let mut map = self.queues.lock().expect("write queue map poisoned");
@ -78,6 +111,55 @@ impl WriteQueueManager {
fresh
}
fn branch_slot(&self, branch: &Option<String>) -> Arc<AsyncMutex<()>> {
let mut map = self
.branch_queues
.lock()
.expect("branch write queue map poisoned");
if let Some(existing) = map.get(branch) {
return Arc::clone(existing);
}
let fresh = Arc::new(AsyncMutex::new(()));
map.insert(branch.clone(), Arc::clone(&fresh));
fresh
}
/// Acquire the coarse effect gate for one graph branch.
///
/// RFC-022-enrolled callers MUST acquire this before any per-table queue.
/// It is an in-process contention optimization only; publisher OCC and
/// recovery remain the correctness authorities.
pub(crate) async fn acquire_branch(
&self,
branch: Option<&str>,
) -> OwnedMutexGuard<()> {
let key = branch.map(str::to_string);
self.branch_slot(&key).lock_owned().await
}
/// Acquire several graph-branch control gates in one deterministic order.
///
/// Native branch create-from reads a source ref and mutates a target ref, so
/// both incarnations must remain stable across its fresh revalidation and
/// visibility point. Sorting/deduping gives branch control the same
/// deadlock-free acquisition rule as [`Self::acquire_many`] gives tables.
pub(crate) async fn acquire_branches(
&self,
branches: &[Option<String>],
) -> Vec<OwnedMutexGuard<()>> {
if branches.is_empty() {
return Vec::new();
}
let mut sorted = branches.to_vec();
sorted.sort();
sorted.dedup();
let mut guards = Vec::with_capacity(sorted.len());
for branch in sorted {
guards.push(self.branch_slot(&branch).lock_owned().await);
}
guards
}
/// Acquire exclusive access to the queue for one `(table_key, branch)`.
///
/// Blocks until the lock is available. Drop the returned guard to
@ -112,6 +194,8 @@ impl WriteQueueManager {
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::write_queue_root_identity;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use tokio::time::timeout;
@ -140,6 +224,23 @@ mod tests {
assert_eq!(guards.len(), 1);
}
#[tokio::test]
async fn acquire_branches_dedupes_main_and_named_keys() {
let qm = WriteQueueManager::new();
let guards = timeout(
Duration::from_secs(2),
qm.acquire_branches(&[
Some("feature".to_string()),
None,
Some("feature".to_string()),
None,
]),
)
.await
.expect("duplicate branch keys must not self-deadlock");
assert_eq!(guards.len(), 2);
}
#[tokio::test]
async fn acquire_many_sorts_keys_deterministically() {
// Two callers passing keys in different orders must acquire in
@ -232,4 +333,55 @@ mod tests {
.await
.expect("same-table-different-branch should not serialize");
}
#[test]
fn opaque_root_registry_shares_manager_across_handles() {
let root = format!("memory://write-queue-registry/{}", ulid::Ulid::new());
let first = WriteQueueManager::for_root(&root);
let second = WriteQueueManager::for_root(&root);
assert!(Arc::ptr_eq(&first, &second));
let other = WriteQueueManager::for_root(&format!("{root}/other"));
assert!(!Arc::ptr_eq(&first, &other));
}
#[test]
fn relative_and_absolute_local_roots_share_manager() {
let relative = PathBuf::from("target")
.join("write-queue-identities")
.join(ulid::Ulid::new().to_string())
.join("graph.omni");
let absolute = std::env::current_dir().unwrap().join(&relative);
let relative_identity = write_queue_root_identity(relative.to_str().unwrap()).unwrap();
let absolute_identity = write_queue_root_identity(absolute.to_str().unwrap()).unwrap();
assert_eq!(relative_identity, absolute_identity);
let first = WriteQueueManager::for_root(&relative_identity);
let second = WriteQueueManager::for_root(&absolute_identity);
assert!(Arc::ptr_eq(&first, &second));
}
#[cfg(unix)]
#[test]
fn real_and_symlinked_local_roots_share_manager_before_init() {
use std::os::unix::fs::symlink;
let parent = tempfile::tempdir().unwrap();
let real_parent = parent.path().join("real");
let alias_parent = parent.path().join("alias");
std::fs::create_dir(&real_parent).unwrap();
symlink(&real_parent, &alias_parent).unwrap();
// The graph suffix deliberately does not exist: init computes its
// queue identity before creating the graph directory.
let real_root = real_parent.join("future").join("graph.omni");
let alias_root = alias_parent.join("future").join("graph.omni");
let real_identity = write_queue_root_identity(real_root.to_str().unwrap()).unwrap();
let alias_identity = write_queue_root_identity(alias_root.to_str().unwrap()).unwrap();
assert_eq!(real_identity, alias_identity);
let first = WriteQueueManager::for_root(&real_identity);
let second = WriteQueueManager::for_root(&alias_identity);
assert!(Arc::ptr_eq(&first, &second));
}
}

View file

@ -22,6 +22,15 @@ pub enum ManifestConflictDetails {
expected: u64,
actual: u64,
},
/// A logical authority value captured during write preparation changed
/// before the manifest visibility decision. Unlike a touched-table
/// version mismatch, this may name a read-only dependency such as the
/// target branch's graph head or schema identity.
ReadSetChanged {
member: String,
expected: Option<String>,
actual: Option<String>,
},
/// Lance's row-level CAS rejected the publish because a concurrent writer
/// landed a row with the same `object_id`. Distinct from
/// `ExpectedVersionMismatch`: the caller's expectations (if any) still
@ -85,6 +94,16 @@ pub enum OmniError {
Manifest(ManifestError),
#[error("merge conflicts: {0:?}")]
MergeConflicts(Vec<MergeConflict>),
/// A durable recovery intent overlaps this write. Its physical effects may
/// already have landed, or it may still be armed before its first effect;
/// either way the sidecar named by `operation_id` must be resolved before
/// the caller retries. Treating this as ordinary OCC would let a writer
/// advance around unresolved commit ownership.
#[error("recovery required for operation {operation_id}: {reason}")]
RecoveryRequired {
operation_id: String,
reason: String,
},
/// Engine-layer policy enforcement (MR-722). Wraps either a policy
/// denial ("you can't do that") or a policy-evaluation failure
/// ("the policy engine itself blew up"). The HTTP layer maps
@ -103,6 +122,16 @@ pub enum OmniError {
}
impl OmniError {
pub(crate) fn is_read_set_changed(&self) -> bool {
matches!(
self,
Self::Manifest(ManifestError {
details: Some(ManifestConflictDetails::ReadSetChanged { .. }),
..
})
)
}
pub fn manifest(message: impl Into<String>) -> Self {
Self::Manifest(ManifestError::new(ManifestErrorKind::BadRequest, message))
}
@ -146,4 +175,37 @@ impl OmniError {
.with_details(ManifestConflictDetails::RowLevelCasContention),
)
}
pub fn manifest_read_set_changed(
member: impl Into<String>,
expected: Option<String>,
actual: Option<String>,
) -> Self {
let member = member.into();
let message = format!(
"write authority '{}' changed during preparation (expected {}, current {}) — reprepare from the current branch state",
member,
expected.as_deref().unwrap_or("<absent>"),
actual.as_deref().unwrap_or("<absent>"),
);
Self::Manifest(
ManifestError::new(ManifestErrorKind::Conflict, message).with_details(
ManifestConflictDetails::ReadSetChanged {
member,
expected,
actual,
},
),
)
}
pub fn recovery_required(
operation_id: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self::RecoveryRequired {
operation_id: operation_id.into(),
reason: reason.into(),
}
}
}

View file

@ -635,16 +635,16 @@ fn row_signature(batch: &RecordBatch, row: usize) -> Result<String> {
/// it is validated like `AdoptWithDelta`; only an empty-delta adopt is skipped.
async fn build_merge_changeset(
db: &Omnigraph,
catalog: &Catalog,
candidates: &HashMap<String, CandidateTableState>,
) -> Result<crate::validate::ChangeSet> {
let catalog = db.catalog();
let mut changeset = crate::validate::ChangeSet::new();
for (table_key, candidate) in candidates {
// Validation reads only id/src/dst + scalar constraint columns; project
// out Vector/Blob so the change-set never holds embeddings (holding the
// delta with embeddings would re-introduce the memory pressure the
// streaming append exists to avoid).
let projection = validation_projection(&catalog, table_key);
let projection = validation_projection(catalog, table_key);
let projection: Vec<&str> = projection.iter().map(String::as_str).collect();
let mut change = crate::validate::TableChange::default();
match candidate {
@ -736,7 +736,7 @@ async fn scan_staged_for_validation(
}
async fn validate_merge_candidates(
db: &Omnigraph,
catalog: &Catalog,
target_snapshot: &Snapshot,
changeset: &crate::validate::ChangeSet,
) -> Result<()> {
@ -746,9 +746,9 @@ async fn validate_merge_candidates(
// uniqueness, edge-RI, and cardinality all route through one evaluator shared
// with (eventually) the write path — closing the merge-vs-write drift.
let committed = crate::validate::CommittedState::merge(target_snapshot);
let constraints = crate::validate::constraints_for(&db.catalog());
let constraints = crate::validate::constraints_for(catalog);
let violations =
crate::validate::evaluate(&constraints, changeset, &committed, &db.catalog()).await?;
crate::validate::evaluate(&constraints, changeset, &committed, catalog).await?;
if violations.is_empty() {
Ok(())
} else {
@ -1233,13 +1233,6 @@ impl Omnigraph {
actor_id,
)?;
self.ensure_schema_apply_idle("branch_merge").await?;
// Converge any pending recovery sidecar before the merge
// captures its target snapshot: the merge's publish would
// otherwise make the drifted Phase-B commit visible as an
// unattributed side effect (manifest catches up to HEAD with no
// recovery audit row) and leave the stale sidecar behind. Runs
// before the merge's own sidecar exists.
self.heal_pending_recovery_sidecars().await?;
self.branch_merge_impl(source, target, actor_id).await
}
@ -1263,6 +1256,34 @@ impl Omnigraph {
));
}
let relevant_branches = [source_branch.as_deref(), target_branch.as_deref()];
// Branch merge is still a legacy per-table publisher, but its graph-ref
// authority must be stable for the complete prepare -> publish window.
// First converge or reject relevant recovery intent, then join the same
// root-shared schema -> branch order used by native branch controls.
// Holding both branch gates through publication prevents a target
// delete/recreate from reusing the branch name underneath a plan (ABA).
self.heal_pending_recovery_sidecars_for_write(&relevant_branches)
.await?;
let _schema_guard = self
.write_queue()
.acquire(&crate::db::manifest::schema_apply_serial_queue_key())
.await;
let _branch_guards = self
.write_queue()
.acquire_branches(&[source_branch.clone(), target_branch.clone()])
.await;
self.ensure_no_pending_recovery_sidecars_under_gates(
&relevant_branches,
"branch_merge",
)
.await?;
self.refresh_coordinator_only().await?;
self.ensure_schema_apply_not_locked("branch_merge").await?;
let merge_catalog = self
.load_accepted_catalog_with_schema_gate_held()
.await?;
let source_head_commit_id = self
.head_commit_id_for_branch(source_branch.as_deref())
.await?
@ -1298,6 +1319,15 @@ impl Omnigraph {
))
.await?
.snapshot;
let target_snapshot = self
.resolved_target(ReadTarget::Branch(
target_branch.clone().unwrap_or_else(|| "main".to_string()),
))
.await?
.snapshot;
crate::failpoints::maybe_fail(
crate::failpoints::names::BRANCH_MERGE_POST_AUTHORITY_CAPTURE,
)?;
// Hold the merge-exclusive mutex across the full swap → operate
// → restore window. Two concurrent branch_merge calls would
// otherwise interleave their three separate `coordinator.write()`
@ -1316,6 +1346,10 @@ impl Omnigraph {
.branch_merge_on_current_target(
&base_snapshot,
&source_snapshot,
&target_snapshot,
merge_catalog.as_ref(),
source_branch.as_deref(),
target_branch.as_deref(),
&target_head_commit_id,
&source_head_commit_id,
is_fast_forward,
@ -1353,8 +1387,8 @@ impl Omnigraph {
// `crates/omnigraph/tests/failpoints.rs`.
//
// Err-path refresh is best-effort: the merge body's error
// (typically the structured `manifest_conflict` from the
// post_queue_snapshot drift check) is the value the caller
// (typically the structured read-set conflict from the fresh
// post-table-gate manifest check) is the value the caller
// needs to see. A refresh-time storage error would replace
// that with a less informative error; the next op or the next
// `Omnigraph::open` will re-sync the coord anyway.
@ -1378,13 +1412,15 @@ impl Omnigraph {
&self,
base_snapshot: &Snapshot,
source_snapshot: &Snapshot,
target_snapshot: &Snapshot,
catalog: &Catalog,
source_branch: Option<&str>,
target_branch: Option<&str>,
target_head_commit_id: &str,
source_head_commit_id: &str,
is_fast_forward: bool,
actor_id: Option<&str>,
) -> Result<MergeOutcome> {
let target_snapshot = self.snapshot().await;
let mut table_keys = HashSet::new();
for entry in base_snapshot.entries() {
table_keys.insert(entry.table_key.clone());
@ -1415,10 +1451,10 @@ impl Omnigraph {
if same_manifest_state(base_entry, target_entry) {
let candidate = classify_adopt(
self,
&self.catalog(),
catalog,
base_snapshot,
source_snapshot,
&target_snapshot,
target_snapshot,
table_key,
)
.await?;
@ -1428,10 +1464,10 @@ impl Omnigraph {
if let Some(staged) = stage_streaming_table_merge(
table_key,
&self.catalog(),
catalog,
base_snapshot,
source_snapshot,
&target_snapshot,
target_snapshot,
&mut conflicts,
)
.await?
@ -1447,8 +1483,8 @@ impl Omnigraph {
return Err(OmniError::MergeConflicts(conflicts));
}
let changeset = build_merge_changeset(self, &candidates).await?;
validate_merge_candidates(self, &target_snapshot, &changeset).await?;
let changeset = build_merge_changeset(self, catalog, &candidates).await?;
validate_merge_candidates(catalog, target_snapshot, &changeset).await?;
// Recovery sidecar: protect the per-table commit_staged loop.
// Pin `RewriteMerged` and `AdoptWithDelta` candidates — both advance
@ -1469,54 +1505,48 @@ impl Omnigraph {
// HEAD unpinned — is closed: `classify_adopt` pre-computes the delta, so a
// HEAD-advancing adopt is `AdoptWithDelta` (pinned here) and an empty-delta
// adopt stays `AdoptSourceState`.
// Acquire per-(table_key, target_branch) queues for every table
// touched by the merge plan. Sorted-order acquisition prevents
// lock-order inversion against concurrent multi-table writers.
// The active branch (set by the caller's `swap_coordinator_for_branch`)
// is the merge target; queue keys are scoped to it because a
// branch_merge writes only to the target branch.
//
// Held across the per-table publish loop and the manifest
// commit + record_merge_commit calls below, so no concurrent
// writer to a touched (table, target_branch) can interleave
// between our commit_staged and our publish.
let active_branch_for_keys = self.active_branch().await;
let merge_queue_keys: Vec<(String, Option<String>)> = ordered_table_keys
.iter()
.filter(|table_key| {
matches!(
candidates.get(*table_key),
Some(CandidateTableState::RewriteMerged(_))
| Some(CandidateTableState::AdoptSourceState { .. })
| Some(CandidateTableState::AdoptWithDelta(_))
)
})
.map(|table_key| (table_key.clone(), active_branch_for_keys.clone()))
.collect();
// This bridge still coexists with legacy maintenance writers that take
// only `(table, branch)` queues. Acquire the conservative all-catalog
// envelope for BOTH source and target, in the global sorted order, then
// re-run the sidecar barrier and re-read both manifest branches before
// Phase A. Planning remains outside table queues, but no plan derived
// from a stale source/target snapshot can cross into physical effects.
let active_branch_for_keys = target_branch.map(str::to_string);
let merge_branches = [
source_branch.map(str::to_string),
active_branch_for_keys.clone(),
];
let merge_queue_keys = self.table_queue_keys_for_branches(&merge_branches, catalog);
let _merge_queue_guards = self.write_queue().acquire_many(&merge_queue_keys).await;
let post_queue_snapshot = self.snapshot().await;
for table_key in &ordered_table_keys {
let Some(candidate) = candidates.get(table_key) else {
continue;
};
if !matches!(
candidate,
CandidateTableState::RewriteMerged(_)
| CandidateTableState::AdoptSourceState { .. }
| CandidateTableState::AdoptWithDelta(_)
) {
continue;
}
let expected = target_snapshot.entry(table_key).map(|e| e.table_version);
let current = post_queue_snapshot
.entry(table_key)
.map(|e| e.table_version);
if expected != current {
return Err(OmniError::manifest_expected_version_mismatch(
table_key.clone(),
expected.unwrap_or(0),
current.unwrap_or(0),
self.ensure_no_pending_recovery_sidecars_under_gates(
&[source_branch, target_branch],
"branch_merge after acquiring source/target table gates",
)
.await?;
let fresh_source_snapshot = self
.fresh_snapshot_for_branch_unchecked(source_branch)
.await?;
let fresh_target_snapshot = self
.fresh_snapshot_for_branch_unchecked(target_branch)
.await?;
for (member, prepared, current) in [
("source", source_snapshot, &fresh_source_snapshot),
("target", target_snapshot, &fresh_target_snapshot),
] {
if prepared.version() != current.version() {
return Err(OmniError::manifest_read_set_changed(
format!(
"branch_merge_{member}:{}",
if member == "source" {
source_branch.unwrap_or("main")
} else {
target_branch.unwrap_or("main")
}
),
Some(prepared.version().to_string()),
Some(current.version().to_string()),
));
}
}
@ -1607,7 +1637,7 @@ impl Omnigraph {
};
let update = match candidate_state {
CandidateTableState::AdoptSourceState { .. } => {
publish_adopted_source_state(self, source_snapshot, &target_snapshot, table_key)
publish_adopted_source_state(self, source_snapshot, target_snapshot, table_key)
.await?
}
CandidateTableState::AdoptWithDelta(delta) => {
@ -1631,6 +1661,9 @@ impl Omnigraph {
// `updates` carry the real per-table final versions (multiple
// commit_staged calls per table, so not derivable from `post_commit_pin`
// alone). A failure here leaves the unconfirmed sidecar → roll back.
crate::failpoints::maybe_fail(
crate::failpoints::names::BRANCH_MERGE_POST_EFFECTS_PRE_CONFIRM,
)?;
if let Some((sidecar, _)) = recovery.as_mut() {
let confirmed_versions: std::collections::HashMap<String, u64> = updates
.iter()

View file

@ -382,21 +382,13 @@ fn predicate_to_sql(
/// Replace specific columns in a RecordBatch with new literal values.
///
/// Blob columns may or may not be present in `batch` depending on the
/// caller's scan projection:
/// - If `batch` does NOT contain a blob column AND it has no assignment,
/// the column is OMITTED from the output. `merge_insert` leaves it
/// untouched.
/// - If `batch` DOES contain a blob column AND it has no assignment, the
/// column is COPIED to the output. This enables coalescing of
/// different-shape updates into a single full-schema merge batch (the
/// per-table accumulator in `MutationStaging` requires consistent
/// schemas across pending batches for `concat_batches`). The
/// round-tripping cost is acceptable for typical agent-driven
/// mutations; tables with large blobs and unassigned-blob updates may
/// want to be split into separate queries.
/// - If a blob column has a string-URI assignment, build the blob array
/// inline.
/// Blob-bearing updates always arrive with the full logical schema. Committed
/// blob payloads were materialized by the caller and rebuilt as logical
/// `Struct<data,uri>` arrays; pending batches already have that shape. An
/// unassigned blob is copied through, while an assigned string URI is rebuilt
/// with the same blob writer used by inserts. Consequently every update batch
/// has the catalog schema and can safely share one pending merge stream with
/// inserts and earlier updates.
fn apply_assignments(
full_schema: &SchemaRef,
batch: &RecordBatch,
@ -404,8 +396,6 @@ fn apply_assignments(
blob_properties: &HashSet<String>,
) -> Result<RecordBatch> {
let mut columns: Vec<ArrayRef> = Vec::with_capacity(full_schema.fields().len());
let mut out_fields: Vec<Field> = Vec::with_capacity(full_schema.fields().len());
for field in full_schema.fields().iter() {
if blob_properties.contains(field.name()) {
if let Some(Literal::String(uri)) = assignments.get(field.name()) {
@ -414,26 +404,25 @@ fn apply_assignments(
for _ in 0..batch.num_rows() {
crate::loader::append_blob_value(&mut builder, uri)?;
}
let blob_field = lance::blob::blob_field(field.name(), true);
out_fields.push(blob_field);
columns.push(
builder
.finish()
.map_err(|e| OmniError::Lance(e.to_string()))?,
);
} else if let Some(col) = batch.column_by_name(field.name()) {
// Unassigned but scan included it: copy through (writes
// back the same blob, no observable change but uniform
// schema for the accumulator).
let blob_field = lance::blob::blob_field(field.name(), field.is_nullable());
out_fields.push(blob_field);
} else {
// Unassigned: the materializing scan must have normalized the
// committed value (or pending value) to the logical blob
// schema, so copying it preserves both bytes and full-schema
// merge compatibility.
let col = batch.column_by_name(field.name()).ok_or_else(|| {
OmniError::Lance(format!(
"blob column '{}' not found in full-schema mutation scan",
field.name()
))
})?;
columns.push(col.clone());
}
// else: scan did not include this blob column and no
// assignment — omit. Caller's accumulator must accept the
// narrower schema (legacy single-merge_insert path).
} else if let Some(lit) = assignments.get(field.name()) {
out_fields.push(field.as_ref().clone());
columns.push(literal_to_typed_array(
lit,
field.data_type(),
@ -446,13 +435,11 @@ fn apply_assignments(
field.name()
))
})?;
out_fields.push(field.as_ref().clone());
columns.push(col.clone());
}
}
RecordBatch::try_new(Arc::new(Schema::new(out_fields)), columns)
.map_err(|e| OmniError::Lance(e.to_string()))
RecordBatch::try_new(full_schema.clone(), columns).map_err(|e| OmniError::Lance(e.to_string()))
}
// ─── Mutation execution ──────────────────────────────────────────────────────
@ -461,15 +448,15 @@ use super::staging::{MutationStaging, PendingMode};
/// Open a sub-table dataset for read or staged write within the current
/// mutation query, capturing pre-write metadata in `staging` on first touch.
/// The captured version is the publisher's CAS fence at end-of-query
/// (per-table OCC).
/// The captured table version is the physical staging baseline. The publisher's
/// logical CAS fence is the enclosing `WriteTxn` authority: native branch
/// identity, exact optional graph head, and accepted schema identity.
///
/// On first touch, opens the dataset at HEAD on the requested branch
/// via `open_for_mutation_on_branch`, which compares Lance HEAD against
/// the manifest's pinned version — that fence is the engine's
/// publisher-style OCC catching cross-writer drift before we make any
/// changes. For delete-only queries, this strict open is also the uncovered
/// drift guard.
/// On first touch, resolves the table from the transaction's pinned base.
/// Strict read-modify-write operations open that exact version and retain the
/// early HEAD-vs-pin drift guard; Insert/Merge may skip the physical open and
/// carry only a reclaimable stage plan. Neither path substitutes for the final
/// branch-wide token check under the effect gates.
///
/// On subsequent touches *within the same query*, Lance HEAD has not moved
/// since first touch — inserts, updates AND deletes all stage their work and
@ -481,8 +468,7 @@ use super::staging::{MutationStaging, PendingMode};
/// touch records another predicate (`record_delete`), and `stage_all` combines
/// them into one staged delete — there is no post-inline-commit reopen to
/// special-case anymore.
impl Omnigraph {
}
impl Omnigraph {}
async fn open_table_for_mutation(
db: &Omnigraph,
@ -495,8 +481,8 @@ async fn open_table_for_mutation(
// `open_for_mutation_on_branch` returns the expected version even when it
// skips the open (collapse #1, the non-strict insert/merge path): the version
// is the pinned base's, identical to the opened handle's `.version()`. Use it
// directly for `ensure_path` so the no-open path still captures the publisher
// CAS fence.
// directly for `ensure_path` so the no-open path retains its exact physical
// staging baseline; the branch-wide WriteTxn is the publisher CAS fence.
let opened = db
.open_for_mutation_on_branch(branch, table_key, op_kind, txn)
.await?;
@ -512,6 +498,7 @@ async fn open_table_for_mutation(
table_key,
opened.full_path.clone(),
opened.table_branch.clone(),
opened.deferred_fork.clone(),
opened.expected_version,
op_kind,
);
@ -648,18 +635,18 @@ impl Omnigraph {
txn: &crate::db::WriteTxn,
) -> Result<()> {
// RI/uniqueness read the write's already-validated pinned base (`txn.base`),
// NOT a fresh `snapshot_for_branch` — which would re-run the schema-contract
// validation the WriteTxn already did once (RFC-013 step 3b capture-once).
// Cardinality reads LIVE HEAD per edge table (the #298 stale-handle fix) via
// the live opener in `CommittedState::write`.
// NOT a fresh `snapshot_for_branch` — per-table resolution must not add a
// schema-contract validation beyond capture + the pre-effect gate.
// Cardinality reads a fresh manifest-visible branch snapshot (the #298
// stale-handle fix) via `CommittedState::write`; it never follows an
// unpublished raw Lance HEAD or a not-yet-created first-touch ref.
let committed =
crate::validate::CommittedState::write(&txn.base, self, txn.branch.as_deref());
// `to_changeset` carries both constructive batches and the ids the delete
// ops captured from their own scans (`deleted_ids`), so the evaluator
// recounts the srcs a delete empties (`@card`) and sees removed rows for
// RI — the faithful change-set the merge path also builds.
crate::validate::validate_changeset(&staging.to_changeset(), &committed, &self.catalog())
.await
crate::validate::validate_changeset(&staging.to_changeset(), &committed, &txn.catalog).await
}
async fn mutate_with_current_actor(
@ -670,15 +657,51 @@ impl Omnigraph {
params: &ParamMap,
actor_id: Option<&str>,
) -> Result<MutationResult> {
// Converge any pending recovery sidecar (a previously failed
// writer's Phase B → Phase C residual) before executing: the
// inline delete path advances Lance HEAD during execution and
// the staged path's commit-time drift guard refuses
// sidecar-covered drift, so a long-lived handle must heal here
// — not at restart. One `list_dir` when no sidecars exist (the
// steady state). MUST run before `open_write_txn` below — the heal
// may advance the manifest, so the pinned base must be captured after.
self.heal_pending_recovery_sidecars().await?;
const MAX_PRE_EFFECT_REPREPARES: usize = 32;
// Resolve request-scoped values such as now() once so a safe
// pre-effect retry does not change the logical input.
let resolved_params = enrich_mutation_params(params)?;
for attempt in 0..=MAX_PRE_EFFECT_REPREPARES {
let mut retryable = false;
match self
.mutate_one_attempt(
branch,
query_source,
query_name,
&resolved_params,
actor_id,
&mut retryable,
)
.await
{
Err(err)
if retryable
&& err.is_read_set_changed()
&& attempt < MAX_PRE_EFFECT_REPREPARES =>
{
tracing::debug!(
attempt = attempt + 1,
branch,
"prepared mutation authority changed before effects; repreparing"
);
self.refresh().await?;
}
result => return result,
}
}
unreachable!("bounded mutation retry loop always returns")
}
async fn mutate_one_attempt(
&self,
branch: &str,
query_source: &str,
query_name: &str,
params: &ParamMap,
actor_id: Option<&str>,
retryable: &mut bool,
) -> Result<MutationResult> {
let requested = Self::normalize_branch_name(branch)?;
// Reject internal `__run__*` / system-prefixed branches at the
// public write boundary. Direct-publish paths assert this
@ -687,62 +710,42 @@ impl Omnigraph {
if let Some(name) = requested.as_deref() {
crate::db::ensure_public_branch_ref(name, "mutate")?;
}
// Capture-once write transaction (RFC-013 step 3b). `open_write_txn`
// validates the schema contract ONCE (it resolves the branch target,
// whose first line is `ensure_schema_state_valid`) and pins the base
// snapshot for this write. Threaded as `Some(&txn)` through execution,
// staging commit, and the manifest publish so the per-table opens and
// the commit-time OCC re-read reuse the pinned base instead of
// re-validating the contract at every resolve point. Captured AFTER the
// recovery heal (which may advance the manifest) and AFTER `requested`
// is known so it pins the post-heal snapshot for the correct branch.
// Stage A: converge any roll-forward-eligible sidecars, then close the
// barrier on every unresolved intent for this graph branch. This MUST
// run before `open_write_txn`: healing may advance the manifest, and a
// deferred Armed intent remains ownership even when no table HEAD moved.
self.heal_pending_recovery_sidecars_for_write(&[requested.as_deref()])
.await?;
// Capture one branch-wide write authority after the recovery barrier:
// native branch identity, exact optional graph head, accepted schema
// identity/catalog, and the base table snapshot. Execution, validation,
// staging, and publication all use this immutable attempt. `commit_all`
// revalidates the complete token under the root-shared schema → branch →
// sorted-table gates before it arms recovery or advances Lance HEAD.
let txn = self.open_write_txn(requested.as_deref()).await?;
let resolved_params = enrich_mutation_params(params)?;
let resolved_params = params.clone();
// Per-query staging accumulator. Inserts and updates push batches
// into `pending`; deletes push predicates into `delete_predicates`. At
// end-of-query, `finalize` issues one `stage_*` + `commit_staged` per
// touched table (inserts/updates/deletes alike), then the publisher
// commits the manifest atomically across all touched tables. Branch is
// threaded explicitly — no coordinator swap.
// Per-query staging accumulator. Inserts and updates push batches into
// `pending`; deletes push predicates into `delete_predicates`. At the
// boundary, `stage_all` prepares one exact transaction per touched table
// and `commit_all` records those identities in a durable schema-v3
// recovery intent before independently advancing the table HEADs. The
// publisher then makes the complete result graph-visible in one manifest
// CAS. Branch is threaded explicitly — no coordinator swap.
let mut staging = MutationStaging::default();
// Lower + validate up front so the touched-table set is known before
// execution. A lowering/validation error returns exactly as it did
// when this happened inside execute_named_mutation.
let ir = self.lower_named_mutation(query_source, query_name)?;
// Up-front fork-queue acquisition (see the loader for the full
// rationale): if this mutation will fork any touched table onto a
// non-main branch, acquire the per-(table, branch) write queues for
// every touched table before the first fork and hold them through the
// publish, so the orphan-fork reclaim can't race a concurrent
// in-process fork. The touched set is derived from the lowered IR.
let fork_queue_guards: Option<(
Vec<(String, Option<String>)>,
Vec<tokio::sync::OwnedMutexGuard<()>>,
)> = if let Some(active) = requested.as_deref() {
let snapshot = self.snapshot_for_branch(Some(active)).await?;
let touched: Vec<(String, Option<String>)> = self
.touched_table_keys(&ir)
.into_iter()
.map(|k| (k, Some(active.to_string())))
.collect();
let needs_fork = touched.iter().any(|(table_key, _)| {
snapshot
.entry(table_key)
.map(|e| e.table_branch.as_deref() != Some(active))
.unwrap_or(false)
});
if needs_fork {
let guards = self.write_queue().acquire_many(&touched).await;
Some((touched, guards))
} else {
None
}
} else {
None
};
let ir = self.lower_named_mutation(&txn.catalog, query_source, query_name)?;
// Only an insert-only mutation is safe to replay automatically after a
// pre-effect authority mismatch. Update/Delete keep strict caller-visible
// `ReadSetChanged`; replaying their stale read-modify-write plan would be
// a semantic rebase rather than a fresh execution contract.
*retryable = ir
.ops
.iter()
.all(|op| matches!(op, MutationOpIR::Insert { .. }));
let exec_result = self
.execute_named_mutation(
@ -750,7 +753,7 @@ impl Omnigraph {
&resolved_params,
requested.as_deref(),
&mut staging,
Some(&txn),
&txn,
)
.await;
@ -760,62 +763,74 @@ impl Omnigraph {
Ok(total) => {
self.validate_staged_mutation(&staging, &txn).await?;
let staged = staging.stage_all(self, requested.as_deref()).await?;
// `_queue_guards` holds per-(table_key, branch) write
// queues acquired inside `commit_all`. Held across the
// manifest publish below so no concurrent writer can
// interleave between our commit_staged and our publish
// (which would correctly fail our CAS but leave Lance
// HEAD advanced — the residual class MR-870 recovers).
crate::failpoints::maybe_fail(
crate::failpoints::names::MUTATION_POST_STAGE_PRE_EFFECT_GATE,
)?;
let lineage_intent = self
.new_lineage_intent_for_branch(requested.as_deref(), actor_id)
.await?;
// `_queue_guards` holds the root-shared schema gate, branch
// effect gate, and sorted table gates acquired by `commit_all`.
// They remain held through manifest publication, covering the
// complete same-process sidecar/effect lifetime. They are a
// local serialization aid; the exact publisher precondition and
// durable v3 recovery plan remain the correctness authorities.
let super::staging::CommittedMutation {
updates,
expected_versions,
sidecar_handle,
guards: _queue_guards,
committed_handles,
} = staged
.commit_all(
self,
requested.as_deref(),
crate::db::manifest::SidecarKind::Mutation,
actor_id,
fork_queue_guards,
Some(&txn),
&txn,
&lineage_intent,
)
.await?;
// Failpoint that wedges the documented finalize→publisher
// residual: per-table `commit_staged` calls already
// advanced Lance HEAD on every touched table; a failure
// injected here mirrors the production-rare case where
// the publisher's CAS pre-check rejects (or the manifest
// write throws) after staged commits succeeded. The
// sidecar written inside `staging.finalize()` persists
// across this failure so the next `Omnigraph::open`'s
// recovery sweep can roll forward — see
// Failpoint for the confirmed-effects → publisher boundary:
// table HEADs have advanced but graph visibility has not. The
// v3 sidecar already contains exact transaction identities,
// immutable manifest delta, and fixed lineage/rollback outcomes.
// Any failure from here is `RecoveryRequired`; synchronous heal
// or a read-write open converges the recorded outcome. See
// `tests/failpoints.rs::recovery_rolls_forward_after_finalize_publisher_failure`.
crate::failpoints::maybe_fail(crate::failpoints::names::MUTATION_POST_FINALIZE_PRE_PUBLISHER)?;
self.commit_updates_on_branch_with_expected(
requested.as_deref(),
&updates,
&expected_versions,
actor_id,
Some(&txn),
committed_handles,
)
.await?;
// Phase C succeeded — sidecar can be deleted. If this
// delete fails, the next open's sweep classifies every
// table as NoMovement (manifest pin == Lance HEAD ==
// post_commit_pin) and the sidecar is treated as a
// stale artifact (cleaned up via the Phase 2 logic).
crate::failpoints::maybe_fail(
crate::failpoints::names::MUTATION_POST_FINALIZE_PRE_PUBLISHER,
)?;
let publish_result = self
.commit_updates_on_branch_with_expected(
requested.as_deref(),
&updates,
&expected_versions,
actor_id,
&txn,
lineage_intent,
)
.await;
if let Err(err) = publish_result {
// A sidecar exists iff at least one table effect was
// committed. Lineage-only / zero-row mutations have no
// physical residual to recover, so preserve their original
// publish error (notably ReadSetChanged) and let the normal
// retry/409 path handle it.
return match sidecar_handle.as_ref() {
Some(handle) => Err(OmniError::recovery_required(
handle.operation_id.clone(),
err.to_string(),
)),
None => Err(err),
};
}
if let Some(handle) = sidecar_handle {
// Best-effort cleanup: the manifest publish already
// succeeded, so the user's mutation is durable. A
// failed delete leaves the sidecar on disk; the
// next open's recovery sweep classifies every table
// as `NoMovement` (manifest pin == Lance HEAD ==
// post_commit_pin) and tidies up. Failing the user
// here would return an error for a write that
// already landed.
// succeeded, so the user's mutation is durable. A failed
// delete leaves a fixed, idempotent v3 outcome for the next
// synchronous heal or read-write open to audit and remove.
// Failing the user here would report an error for a write
// that already landed.
if let Err(err) =
crate::db::manifest::delete_sidecar(&handle, self.storage_adapter()).await
{
@ -841,13 +856,14 @@ impl Omnigraph {
/// is unchanged.
fn lower_named_mutation(
&self,
catalog: &omnigraph_compiler::catalog::Catalog,
query_source: &str,
query_name: &str,
) -> Result<omnigraph_compiler::ir::MutationIR> {
let query_decl = omnigraph_compiler::find_named_query(query_source, query_name)
.map_err(|e| OmniError::manifest(e.to_string()))?;
let checked = typecheck_query_decl(&self.catalog(), &query_decl)?;
let checked = typecheck_query_decl(catalog, &query_decl)?;
match checked {
CheckedQuery::Mutation(_) => {}
CheckedQuery::Read(_) => {
@ -863,58 +879,13 @@ impl Omnigraph {
Ok(ir)
}
/// The COMPLETE set of `(node|edge):{type}` table keys a mutation IR can
/// touch at execution time, keyed as `MutationStaging`/`commit_all` key
/// them. Must be a superset of everything execution forks/commits, since
/// it drives the up-front fork-queue acquisition and `commit_all`'s
/// held-guard coverage check — a miss means an unserialized fork/commit.
///
/// The set is a pure function of (IR ops + catalog). For each op it mirrors
/// the execute path's node-vs-edge dispatch (`node_types` first, then
/// `edge_types`). A `delete <Node>` additionally **cascades** to every edge
/// type whose endpoint is that node (see `execute_delete_node`), forking
/// those edge tables during execution — so they are included here, derived
/// the same way the executor derives them (`from_type`/`to_type` match).
/// Unknown types are skipped (the execute path surfaces the error).
/// Sorted + deduped for one-shot `acquire_many`.
fn touched_table_keys(&self, ir: &omnigraph_compiler::ir::MutationIR) -> Vec<String> {
use omnigraph_compiler::ir::MutationOpIR;
let catalog = self.catalog();
let mut keys: Vec<String> = Vec::new();
for op in &ir.ops {
let type_name = match op {
MutationOpIR::Insert { type_name, .. }
| MutationOpIR::Update { type_name, .. }
| MutationOpIR::Delete { type_name, .. } => type_name,
};
if catalog.node_types.contains_key(type_name) {
keys.push(format!("node:{type_name}"));
// A node delete cascades to every edge touching this node type,
// forking those edge tables. Include them so the up-front
// acquisition covers the cascade (mirrors execute_delete_node).
if matches!(op, MutationOpIR::Delete { .. }) {
for (edge_name, edge_type) in &catalog.edge_types {
if edge_type.from_type == *type_name || edge_type.to_type == *type_name {
keys.push(format!("edge:{edge_name}"));
}
}
}
} else if catalog.edge_types.contains_key(type_name) {
keys.push(format!("edge:{type_name}"));
}
}
keys.sort();
keys.dedup();
keys
}
async fn execute_named_mutation(
&self,
ir: &omnigraph_compiler::ir::MutationIR,
params: &ParamMap,
branch: Option<&str>,
staging: &mut MutationStaging,
txn: Option<&crate::db::WriteTxn>,
txn: &crate::db::WriteTxn,
) -> Result<MutationResult> {
let mut total = MutationResult::default();
for op in &ir.ops {
@ -932,7 +903,13 @@ impl Omnigraph {
predicate,
} => {
self.execute_update(
type_name, assignments, predicate, params, branch, staging, txn,
type_name,
assignments,
predicate,
params,
branch,
staging,
txn,
)
.await?
}
@ -957,18 +934,19 @@ impl Omnigraph {
params: &ParamMap,
branch: Option<&str>,
staging: &mut MutationStaging,
txn: Option<&crate::db::WriteTxn>,
txn: &crate::db::WriteTxn,
) -> Result<MutationResult> {
let mut resolved: HashMap<String, Literal> = HashMap::new();
for a in assignments {
resolved.insert(a.property.clone(), resolve_expr_value(&a.value, params)?);
}
let is_node = self.catalog().node_types.contains_key(type_name);
let is_edge = self.catalog().edge_types.contains_key(type_name);
let catalog = &txn.catalog;
let is_node = catalog.node_types.contains_key(type_name);
let is_edge = catalog.edge_types.contains_key(type_name);
if is_node {
let node_type = &self.catalog().node_types[type_name];
let node_type = &catalog.node_types[type_name];
let schema = node_type.arrow_schema.clone();
let blob_props = node_type.blob_properties.clone();
let id = if let Some(key_prop) = node_type.key_property() {
@ -1001,7 +979,8 @@ impl Omnigraph {
// only `ensure_path`'s captured version (read inside
// `open_table_for_mutation`) is used downstream.
let (_ds, _full_path, _table_branch) =
open_table_for_mutation(self, staging, branch, &table_key, insert_kind, txn).await?;
open_table_for_mutation(self, staging, branch, &table_key, insert_kind, Some(txn))
.await?;
// Accumulate. @key inserts go into the Merge stream (so a
// later update on the same id coalesces correctly); no-key
// inserts go into the Append stream.
@ -1017,26 +996,28 @@ impl Omnigraph {
affected_edges: 0,
})
} else if is_edge {
let edge_type = &self.catalog().edge_types[type_name];
let edge_type = &catalog.edge_types[type_name];
let schema = edge_type.arrow_schema.clone();
let blob_props = edge_type.blob_properties.clone();
let id = ulid::Ulid::new().to_string();
let batch = build_insert_batch(&schema, &id, &resolved, &blob_props)?;
// Validation (edge-RI, enum, unique, @card against LIVE HEAD) runs
// Validation (edge-RI, enum, unique, @card against the live
// manifest-visible branch snapshot) runs
// end-of-query via the evaluator.
let table_key = format!("edge:{}", type_name);
// Capture pre-write metadata on first touch (ensure_path). Edge
// inserts are non-strict, so with a `WriteTxn` this opens NOTHING
// (collapse #1) and the handle is discarded — validation, including
// `@card` against LIVE HEAD, runs end-of-query via the evaluator.
// `@card` against the live committed branch snapshot, runs
// end-of-query via the evaluator.
let (_handle, _full_path, _table_branch) = open_table_for_mutation(
self,
staging,
branch,
&table_key,
crate::db::MutationOpKind::Insert,
txn,
Some(txn),
)
.await?;
// Accumulate the new edge row. Edge IDs are ULID-generated so
@ -1062,10 +1043,11 @@ impl Omnigraph {
params: &ParamMap,
branch: Option<&str>,
staging: &mut MutationStaging,
txn: Option<&crate::db::WriteTxn>,
txn: &crate::db::WriteTxn,
) -> Result<MutationResult> {
let catalog = &txn.catalog;
// Defense in depth: ensure this is a node type
if !self.catalog().node_types.contains_key(type_name) {
if !catalog.node_types.contains_key(type_name) {
return Err(OmniError::manifest(format!(
"update is only supported for node types, not '{}'",
type_name
@ -1073,7 +1055,7 @@ impl Omnigraph {
}
// Reject updates to @key properties — identity is immutable
if let Some(key_prop) = self.catalog().node_types[type_name].key_property() {
if let Some(key_prop) = catalog.node_types[type_name].key_property() {
if assignments.iter().any(|a| a.property == key_prop) {
return Err(OmniError::manifest(format!(
"cannot update @key property '{}' — delete and re-insert instead",
@ -1083,8 +1065,8 @@ impl Omnigraph {
}
let pred_sql = predicate_to_sql(predicate, params, false)?;
let schema = self.catalog().node_types[type_name].arrow_schema.clone();
let blob_props = self.catalog().node_types[type_name].blob_properties.clone();
let schema = catalog.node_types[type_name].arrow_schema.clone();
let blob_props = catalog.node_types[type_name].blob_properties.clone();
let table_key = format!("node:{}", type_name);
let (handle, _full_path, _table_branch) = open_table_for_mutation(
@ -1093,7 +1075,7 @@ impl Omnigraph {
branch,
&table_key,
crate::db::MutationOpKind::Update,
txn,
Some(txn),
)
.await?;
// Update is a STRICT op, so collapse #1 never skips its open — the
@ -1101,25 +1083,9 @@ impl Omnigraph {
let ds = handle.expect("strict Update op always opens its dataset");
// Scan committed via Lance + apply the same predicate to pending
// batches via DataFusion `MemTable` (read-your-writes for prior
// ops in this query). The pending side may include rows from
// earlier `insert` / `update` ops on the same table.
//
// For blob tables we project away the blob columns: Lance's
// scanner doesn't accept the standard projection path on blob
// descriptors and would panic with a `Field::project` assertion.
// The downstream `apply_assignments` synthesizes blob columns
// from explicit assignments and omits unassigned blobs (Lance's
// merge_insert leaves them untouched). Tables without blob
// columns scan the full schema unprojected.
let non_blob_cols: Vec<&str> = schema
.fields()
.iter()
.filter(|f| !blob_props.contains(f.name()))
.map(|f| f.name().as_str())
.collect();
let projection: Option<&[&str]> =
(!blob_props.is_empty()).then_some(non_blob_cols.as_slice());
// batches via DataFusion `MemTable` (read-your-writes for prior ops in
// this query). The pending side may include rows from earlier
// `insert` / `update` ops on the same table.
let pending_batches = staging.pending_batches(&table_key);
let pending_schema = staging.pending_schema(&table_key);
// Use merge semantics on the union: a committed row whose `id`
@ -1128,17 +1094,34 @@ impl Omnigraph {
// otherwise the predicate runs against stale committed values
// and a chained `update where <pred>` can match a row whose
// pending value no longer satisfies <pred>.
let batches = self
.storage()
.scan_with_pending(
&ds,
pending_batches,
pending_schema,
projection,
Some(&pred_sql),
Some("id"),
)
.await?;
// A blob-v2 scan normally yields physical descriptor structs, which
// cannot be fed back to the full-schema merge writer. Select matched
// committed row ids without projecting blobs, then take and rebuild
// only those payloads into the logical blob schema before unioning
// pending rows. This keeps correctness independent of whether an id
// index happens to steer Lance onto its legacy partial-column plan.
let batches = if blob_props.is_empty() {
self.storage()
.scan_with_pending(
&ds,
pending_batches,
pending_schema,
None,
Some(&pred_sql),
Some("id"),
)
.await?
} else {
self.storage()
.scan_with_pending_materialized_blobs(
&ds,
pending_batches,
pending_schema,
Some(&pred_sql),
Some("id"),
)
.await?
};
if batches.is_empty() || batches.iter().all(|b| b.num_rows() == 0) {
return Ok(MutationResult {
@ -1148,14 +1131,9 @@ impl Omnigraph {
}
// Concat the matched batches (committed + pending) into one. The
// helper trusts that both sides share a schema — Lance returns
// dataset-schema-ordered columns and DataFusion returns
// MemTable-schema-ordered columns; both should match the catalog's
// arrow_schema when the projection is consistent. If they
// diverge (typically a blob-table mid-schema-shift), the helper
// surfaces a clear error directing the caller to split the
// mutation.
let matched = concat_match_batches_to_schema(&schema, &blob_props, batches)?;
// helper binds both sides to the catalog's full logical schema. Any
// divergence here is an internal scan/staging contract violation.
let matched = concat_match_batches_to_schema(&schema, batches)?;
let affected_count = matched.num_rows();
@ -1187,9 +1165,9 @@ impl Omnigraph {
params: &ParamMap,
branch: Option<&str>,
staging: &mut MutationStaging,
txn: Option<&crate::db::WriteTxn>,
txn: &crate::db::WriteTxn,
) -> Result<MutationResult> {
let is_node = self.catalog().node_types.contains_key(type_name);
let is_node = txn.catalog.node_types.contains_key(type_name);
if is_node {
self.execute_delete_node(type_name, predicate, params, branch, staging, txn)
.await
@ -1206,7 +1184,7 @@ impl Omnigraph {
params: &ParamMap,
branch: Option<&str>,
staging: &mut MutationStaging,
txn: Option<&crate::db::WriteTxn>,
txn: &crate::db::WriteTxn,
) -> Result<MutationResult> {
let pred_sql = predicate_to_sql(predicate, params, false)?;
@ -1217,7 +1195,7 @@ impl Omnigraph {
branch,
&table_key,
crate::db::MutationOpKind::Delete,
txn,
Some(txn),
)
.await?;
// Delete is a STRICT op, so collapse #1 never skips its open.
@ -1256,7 +1234,9 @@ impl Omnigraph {
// HEAD only at the unified end-of-query commit — no inline residual.
// `open_table_for_mutation` above already captured the table's
// path/version/op-kind via `ensure_path`.
crate::failpoints::maybe_fail(crate::failpoints::names::MUTATION_DELETE_NODE_PRE_PRIMARY_DELETE)?;
crate::failpoints::maybe_fail(
crate::failpoints::names::MUTATION_DELETE_NODE_PRE_PRIMARY_DELETE,
)?;
staging.record_deleted_ids(&table_key, &deleted_ids);
staging.record_delete(&table_key, pred_sql.clone());
@ -1267,8 +1247,8 @@ impl Omnigraph {
.collect();
let id_list = escaped.join(", ");
let edge_info: Vec<(String, String, String)> = self
.catalog()
let edge_info: Vec<(String, String, String)> = txn
.catalog
.edge_types
.iter()
.map(|(name, et)| (name.clone(), et.from_type.clone(), et.to_type.clone()))
@ -1294,7 +1274,7 @@ impl Omnigraph {
branch,
&edge_table_key,
crate::db::MutationOpKind::Delete,
txn,
Some(txn),
)
.await?;
// Delete is a STRICT op, so collapse #1 never skips its open.
@ -1310,8 +1290,10 @@ impl Omnigraph {
// by both a cascade and an explicit `delete <Edge>` — is counted
// once. Record the ORIGINAL cascade filter (the combined staged
// delete removes the union); skip only when nothing NEW matches.
let count_filter =
dedup_delete_filter(&cascade_filter, staging.recorded_delete_predicates(&edge_table_key));
let count_filter = dedup_delete_filter(
&cascade_filter,
staging.recorded_delete_predicates(&edge_table_key),
);
// Scan (not count) the cascade-removed edge ids so validation
// recounts the OTHER endpoint's @card after the cascade; `len()` is
// the affected count.
@ -1347,7 +1329,7 @@ impl Omnigraph {
params: &ParamMap,
branch: Option<&str>,
staging: &mut MutationStaging,
txn: Option<&crate::db::WriteTxn>,
txn: &crate::db::WriteTxn,
) -> Result<MutationResult> {
let pred_sql = predicate_to_sql(predicate, params, true)?;
@ -1358,7 +1340,7 @@ impl Omnigraph {
branch,
&table_key,
crate::db::MutationOpKind::Delete,
txn,
Some(txn),
)
.await?;
// Delete is a STRICT op, so collapse #1 never skips its open.
@ -1420,23 +1402,20 @@ fn ids_from_batches(batches: &[RecordBatch]) -> Vec<String> {
/// `scan_with_pending` returns committed-side and pending-side batches in
/// order; both should share a schema if pending was produced through
/// `apply_assignments` with full-schema scan input. If schemas drift,
/// surface a clear error so the user can split the query.
/// surface the internal contract failure at the mutation boundary.
fn concat_match_batches_to_schema(
_schema: &SchemaRef,
_blob_properties: &HashSet<String>,
schema: &SchemaRef,
batches: Vec<RecordBatch>,
) -> Result<RecordBatch> {
if batches.len() == 1 {
return Ok(batches.into_iter().next().unwrap());
let batch = batches.into_iter().next().unwrap();
return RecordBatch::try_new(schema.clone(), batch.columns().to_vec())
.map_err(|e| OmniError::Lance(e.to_string()));
}
let common = batches[0].schema();
arrow_select::concat::concat_batches(&common, &batches).map_err(|e| {
arrow_select::concat::concat_batches(schema, &batches).map_err(|e| {
OmniError::Lance(format!(
"scan_with_pending returned batches with mismatched schemas \
across the committed/pending boundary; this typically indicates \
a blob-column shape mismatch between the committed table and a \
prior in-query insert/update. Split blob-touching mutations \
into separate queries. ({})",
"mutation scan returned batches that violate the full logical schema \
across the committed/pending boundary ({})",
e
))
})

File diff suppressed because it is too large Load diff

View file

@ -39,31 +39,87 @@ pub(crate) fn maybe_fail_retryable_contention(name: &str) -> Result<()> {
/// compile error rather than a silently-never-firing failpoint.
pub mod names {
pub const BRANCH_DELETE_BEFORE_TABLE_CLEANUP: &str = "branch_delete.before_table_cleanup";
pub const BRANCH_MERGE_ADOPT_AFTER_APPEND_PRE_UPSERT: &str = "branch_merge.adopt_after_append_pre_upsert";
pub const BRANCH_MERGE_ADOPT_AFTER_UPSERT_PRE_DELETE: &str = "branch_merge.adopt_after_upsert_pre_delete";
pub const BRANCH_MERGE_POST_PHASE_B_PRE_MANIFEST_COMMIT: &str = "branch_merge.post_phase_b_pre_manifest_commit";
pub const BRANCH_MERGE_REWRITE_AFTER_DELETE_PRE_INDEX: &str = "branch_merge.rewrite_after_delete_pre_index";
pub const BRANCH_MERGE_REWRITE_AFTER_MERGE_PRE_DELETE: &str = "branch_merge.rewrite_after_merge_pre_delete";
/// Branch delete holds the schema, target-branch, and fresh-catalog table
/// envelope and has completed its final recovery check, before the native
/// manifest-ref mutation.
pub const BRANCH_DELETE_POST_TABLE_GATES: &str = "branch_delete.post_table_gates";
/// After native branch control completed its first recovery barrier, before
/// it acquires schema -> branch -> table gates and performs the final check.
pub const BRANCH_CONTROL_POST_RECOVERY_BARRIER: &str =
"branch_control.post_recovery_barrier";
pub const BRANCH_MERGE_ADOPT_AFTER_APPEND_PRE_UPSERT: &str =
"branch_merge.adopt_after_append_pre_upsert";
pub const BRANCH_MERGE_ADOPT_AFTER_UPSERT_PRE_DELETE: &str =
"branch_merge.adopt_after_upsert_pre_delete";
/// Source/target heads and snapshots have been captured while the schema
/// and both branch-incarnation gates are held, before merge planning or
/// any durable table effect.
pub const BRANCH_MERGE_POST_AUTHORITY_CAPTURE: &str =
"branch_merge.post_authority_capture";
pub const BRANCH_MERGE_POST_PHASE_B_PRE_MANIFEST_COMMIT: &str =
"branch_merge.post_phase_b_pre_manifest_commit";
/// Every merge table effect is complete, but the sidecar is still in its
/// pre-confirmation shape.
pub const BRANCH_MERGE_POST_EFFECTS_PRE_CONFIRM: &str =
"branch_merge.post_effects_pre_confirm";
pub const BRANCH_MERGE_REWRITE_AFTER_DELETE_PRE_INDEX: &str =
"branch_merge.rewrite_after_delete_pre_index";
pub const BRANCH_MERGE_REWRITE_AFTER_MERGE_PRE_DELETE: &str =
"branch_merge.rewrite_after_merge_pre_delete";
pub const CLASSIFY_FRESH_READ: &str = "classify.fresh_read";
pub const CLEANUP_RECONCILE_FORK: &str = "cleanup.reconcile_fork";
/// After cleanup's fast empty-sidecar probe, before it acquires the closed
/// schema/branch/table GC gate set and performs the authoritative recheck.
pub const CLEANUP_POST_RECOVERY_CHECK_PRE_GATES: &str =
"cleanup.post_recovery_check_pre_gates";
pub const CLEANUP_RESOLVE_BRANCH_SNAPSHOT: &str = "cleanup.resolve_branch_snapshot";
pub const CLEANUP_TABLE_GC: &str = "cleanup.table_gc";
pub const ENSURE_INDICES_POST_PHASE_B_PRE_MANIFEST_COMMIT: &str = "ensure_indices.post_phase_b_pre_manifest_commit";
pub const ENSURE_INDICES_POST_STAGE_PRE_COMMIT_BTREE: &str = "ensure_indices.post_stage_pre_commit_btree";
pub const ENSURE_INDICES_POST_PHASE_B_PRE_MANIFEST_COMMIT: &str =
"ensure_indices.post_phase_b_pre_manifest_commit";
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.
pub const FORK_POST_CREATE_PRE_OPEN: &str = "fork.post_create_pre_open";
pub const GRAPH_PUBLISH_AFTER_MANIFEST_COMMIT: &str = "graph_publish.after_manifest_commit";
pub const GRAPH_PUBLISH_BEFORE_COMMIT_APPEND: &str = "graph_publish.before_commit_append";
pub const INIT_AFTER_COORDINATOR_INIT: &str = "init.after_coordinator_init";
pub const INIT_AFTER_SCHEMA_CONTRACT_WRITTEN: &str = "init.after_schema_contract_written";
pub const INIT_AFTER_SCHEMA_PG_WRITTEN: &str = "init.after_schema_pg_written";
pub const MUTATION_DELETE_NODE_PRE_PRIMARY_DELETE: &str = "mutation.delete_node_pre_primary_delete";
pub const MUTATION_DELETE_NODE_PRE_PRIMARY_DELETE: &str =
"mutation.delete_node_pre_primary_delete";
/// After every deferred first-touch table ref is created under a durable
/// v3 sidecar, before any staged data transaction advances target HEAD.
pub const MUTATION_POST_FORK_PRE_COMMIT: &str = "mutation.post_fork_pre_commit";
/// After the v3 ownership sidecar is durable but before the first deferred
/// named-table ref is created. Recovery must accept the absent target ref.
pub const MUTATION_POST_SIDECAR_PRE_FORK: &str = "mutation.post_sidecar_pre_fork";
/// Deterministic OCC rendezvous after a mutation has validated and staged
/// its complete attempt, but before the RFC-022 branch effect gate is
/// acquired and the write authority token is revalidated. Tests park the
/// first writer here, commit a conflicting second writer, then prove the
/// first attempt is discarded and validation is rerun from a fresh token.
pub const MUTATION_POST_STAGE_PRE_EFFECT_GATE: &str = "mutation.post_stage_pre_effect_gate";
pub const MUTATION_POST_FINALIZE_PRE_PUBLISHER: &str = "mutation.post_finalize_pre_publisher";
/// Open owns the schema gate and is about to read source/IR/state as one
/// catalog view.
pub const OPEN_BEFORE_SCHEMA_CONTRACT_READ: &str = "open.before_schema_contract_read";
pub const OPTIMIZE_BEFORE_COMPACT: &str = "optimize.before_compact";
pub const OPTIMIZE_INJECT_REINDEX_CONFLICT: &str = "optimize.inject_reindex_conflict";
pub const OPTIMIZE_POST_PHASE_B_PRE_MANIFEST_COMMIT: &str = "optimize.post_phase_b_pre_manifest_commit";
pub const OPTIMIZE_POST_PHASE_B_PRE_MANIFEST_COMMIT: &str =
"optimize.post_phase_b_pre_manifest_commit";
pub const RECOVERY_BEFORE_ROLL_FORWARD_PUBLISH: &str = "recovery.before_roll_forward_publish";
/// Recovery has listed/parsed its discovery snapshot but has not yet taken
/// per-sidecar gates. Tests rewrite confirmation state in this window.
pub const RECOVERY_POST_LIST_PRE_GATES: &str = "recovery.post_list_pre_gates";
pub const RECOVERY_ORPHAN_DISCARD_AUDIT_APPEND: &str = "recovery.orphan_discard_audit_append";
/// After the fixed rollback lineage/table-pin publish is durable, before
/// its operator-facing audit row is appended.
pub const RECOVERY_POST_ROLLBACK_PUBLISH_PRE_AUDIT: &str =
"recovery.post_rollback_publish_pre_audit";
pub const RECOVERY_RECORD_AUDIT: &str = "recovery.record_audit";
pub const RECOVERY_SIDECAR_CONFIRM: &str = "recovery.sidecar_confirm";
pub const RECOVERY_SIDECAR_DELETE: &str = "recovery.sidecar_delete";
@ -72,6 +128,9 @@ pub mod names {
pub const SCHEMA_APPLY_AFTER_MANIFEST_COMMIT: &str = "schema_apply.after_manifest_commit";
pub const SCHEMA_APPLY_AFTER_STAGING_WRITE: &str = "schema_apply.after_staging_write";
pub const SCHEMA_APPLY_BEFORE_STAGING_WRITE: &str = "schema_apply.before_staging_write";
/// Reload owns the schema gate and is about to read/publish one contract view.
pub const SCHEMA_RELOAD_BEFORE_CONTRACT_READ: &str =
"schema_reload.before_contract_read";
/// Injects a retryable `RowLevelCasContention` from `load_publish_state` so a
/// test can prove the publisher's outer retry re-runs the load.
pub const PUBLISH_LOAD_STATE_RETRYABLE_CONTENTION: &str =

View file

@ -313,6 +313,8 @@ pub struct StorageReadCounts {
pub exists: AtomicU64,
pub read_text_versioned: AtomicU64,
pub list_dir: AtomicU64,
pub write_text: AtomicU64,
pub delete: AtomicU64,
}
impl StorageReadCounts {
@ -328,6 +330,12 @@ impl StorageReadCounts {
pub fn list_dir(&self) -> u64 {
self.list_dir.load(Ordering::Relaxed)
}
pub fn write_text(&self) -> u64 {
self.write_text.load(Ordering::Relaxed)
}
pub fn delete(&self) -> u64 {
self.delete.load(Ordering::Relaxed)
}
}
/// Boundary decorator over a [`StorageAdapter`] that counts read-facing calls.
@ -360,6 +368,7 @@ impl StorageAdapter for CountingStorageAdapter {
}
async fn write_text(&self, uri: &str, contents: &str) -> Result<()> {
self.counts.write_text.fetch_add(1, Ordering::Relaxed);
self.inner.write_text(uri, contents).await
}
@ -377,6 +386,7 @@ impl StorageAdapter for CountingStorageAdapter {
}
async fn delete(&self, uri: &str) -> Result<()> {
self.counts.delete.fetch_add(1, Ordering::Relaxed);
self.inner.delete(uri).await
}

View file

@ -187,17 +187,6 @@ impl Omnigraph {
&omnigraph_policy::ResourceScope::Branch(branch.to_string()),
actor_id,
)?;
// Schema-contract validation is captured ONCE per write via the
// `WriteTxn` opened in `load_jsonl_reader` (after branch resolution).
// The redundant `ensure_schema_state_valid` that used to run here is
// subsumed by `open_write_txn`'s `resolved_branch_target` call.
// Converge any pending recovery sidecar (a previously failed
// writer's Phase B → Phase C residual) before staging anything:
// without this, sidecar-covered drift wedges every load on the
// commit-time drift guard until a process restart — `repair`
// refuses while a sidecar is pending. One `list_dir` when no
// sidecars exist (the steady state).
self.heal_pending_recovery_sidecars().await?;
// Reject internal `__run__*` / system-prefixed branches at the
// public write boundary. Direct-publish paths assert this
// explicitly so a caller can't write to legacy or system
@ -215,6 +204,23 @@ impl Omnigraph {
}
None => None,
};
// Schema/catalog authority is captured once via the `WriteTxn` (plus its
// cheap trailing identity-marker fence); the only second full validation
// is the required pre-effect recheck under gates. Per-table resolution
// performs no additional contract reads.
//
// Stage A precedes both an implicit target-branch fork and data staging.
// The target branch and an explicit base are read/write authority for the
// operation, so an unresolved intent on either closes the barrier. The
// helper folds `Some("main")` to main's canonical `None` identity.
let mut recovery_branches = vec![requested.as_deref()];
if base.is_some() {
// `base_branch` retains `Some("main")` for the result DTO; the
// barrier helper canonicalizes it to main's `None` identity.
recovery_branches.push(base_branch.as_deref());
}
self.heal_pending_recovery_sidecars_for_write(&recovery_branches)
.await?;
// Fork-if-missing only when a base branch was explicitly given.
// `requested == None` is `main`, which always exists.
let mut branch_created = false;
@ -238,7 +244,7 @@ impl Omnigraph {
}
// Direct-to-target writes: no Run state machine, no `__run__` staging
// branch. Cross-table OCC is enforced by the publisher's
// `expected_table_versions` CAS inside `load_jsonl_reader`.
// `expected_table_versions` CAS inside the load attempt.
let mut result = self
.load_direct_on_branch(requested.as_deref(), data, mode, actor_id)
.await?;
@ -274,8 +280,7 @@ impl Omnigraph {
mode: LoadMode,
actor_id: Option<&str>,
) -> Result<LoadResult> {
let reader = BufReader::new(Cursor::new(data.as_bytes()));
load_jsonl_reader(self, branch, reader, mode, actor_id).await
load_jsonl_data(self, branch, data, mode, actor_id).await
}
}
@ -312,14 +317,55 @@ impl LoadResult {
}
}
async fn load_jsonl_reader<R: BufRead>(
async fn load_jsonl_data(
db: &Omnigraph,
branch: Option<&str>,
data: &str,
mode: LoadMode,
actor_id: Option<&str>,
) -> Result<LoadResult> {
const MAX_PRE_EFFECT_REPREPARES: usize = 32;
// Every public load entry point already owns a stable `&str` payload
// (`load_file_as` reads its file once). Replay that slice directly on a
// pre-effect retry; copying it into a second raw byte buffer would double
// peak input memory before the parser's per-type materialization.
let retryable = matches!(mode, LoadMode::Append | LoadMode::Merge);
for attempt in 0..=MAX_PRE_EFFECT_REPREPARES {
let replay = BufReader::new(Cursor::new(data.as_bytes()));
match load_jsonl_reader_once(db, branch, replay, mode, actor_id).await {
Err(err)
if retryable
&& err.is_read_set_changed()
&& attempt < MAX_PRE_EFFECT_REPREPARES =>
{
tracing::debug!(
attempt = attempt + 1,
branch = branch.unwrap_or("main"),
"prepared load authority changed before effects; repreparing"
);
db.refresh().await?;
}
result => return result,
}
}
unreachable!("bounded load retry loop always returns")
}
async fn load_jsonl_reader_once<R: BufRead>(
db: &Omnigraph,
branch: Option<&str>,
reader: R,
mode: LoadMode,
actor_id: Option<&str>,
) -> Result<LoadResult> {
let catalog = db.catalog().clone();
// Capture the manifest/schema authority before interpreting any input. The
// catalog rides the WriteTxn and was built from the exact accepted IR named
// by its schema token; a long-lived handle's global catalog may legitimately
// lag a schema apply completed through another handle.
let txn = db.open_write_txn(branch).await?;
let catalog = Arc::clone(&txn.catalog);
let snapshot = txn.base.clone();
// Phase 1: Parse all lines, spool into per-type collections
let mut node_rows: HashMap<String, Vec<JsonValue>> = HashMap::new();
@ -400,16 +446,10 @@ async fn load_jsonl_reader<R: BufRead>(
// inline path.
let mut result = LoadResult::default();
// Capture-once write transaction (RFC-013 step 3b). `open_write_txn`
// validates the schema contract ONCE and pins the base snapshot. Threaded
// as `Some(&txn)` through the per-table opens and the manifest publish so
// each resolve point reuses the pinned base instead of re-validating the
// contract. The branch already exists here (fork-if-missing ran in
// `load_as` before this), so this captures the post-fork snapshot. The
// load's own base read (`db.snapshot_for_branch` previously) is the same
// per-branch snapshot, so reuse `txn.base` for it — dropping a validation.
let txn = db.open_write_txn(branch).await?;
let snapshot = txn.base.clone();
// The branch-wide WriteTxn captured above is threaded through every table
// open and the manifest publish, so the parsed batches, validation catalog,
// base snapshot, native branch identity, exact graph head, and schema
// identity form one immutable authority unit.
let mut staging = MutationStaging::default();
let pending_mode = match mode {
LoadMode::Merge => PendingMode::Merge,
@ -419,56 +459,17 @@ async fn load_jsonl_reader<R: BufRead>(
LoadMode::Append => PendingMode::Append,
LoadMode::Overwrite => PendingMode::Overwrite,
};
// Map LoadMode to MutationOpKind for the version-check policy.
// Append/Merge skip the strict pre-stage check (concurrency-safe
// under the per-(table, branch) queue + publisher CAS); Overwrite
// uses the strict check because it truncates and replaces the
// dataset — concurrent advances change what "replace" means.
// Map LoadMode to the early table-version policy. Append/Merge may stage
// reclaimable files before the effect gates, then revalidate the complete
// branch token and fully reprepare on a bounded pre-effect conflict.
// Overwrite keeps the strict early check because it replaces the image; its
// later branch-wide mismatch surfaces `ReadSetChanged` without replay.
let load_op_kind = match mode {
LoadMode::Append => crate::db::MutationOpKind::Insert,
LoadMode::Merge => crate::db::MutationOpKind::Merge,
LoadMode::Overwrite => crate::db::MutationOpKind::SchemaRewrite,
};
// Up-front fork-queue acquisition. The first write to a table on a
// non-main branch forks it (create_branch), which advances Lance state
// before the manifest publish; the reclaim of any manifest-unreferenced
// leftover (`reclaim_orphaned_fork_and_refork`) must not race a concurrent
// in-process fork. So when this load will fork at least one touched table,
// acquire the per-(table, branch) write queues for ALL touched tables up
// front (one sorted `acquire_many`, keyed uniformly by the target branch
// so it covers what `commit_all` recomputes) and hold them through the
// publish. Main-branch loads never fork; branch loads where every touched
// table is already forked skip this and let `commit_all` acquire at commit.
let fork_queue_guards: Option<(
Vec<(String, Option<String>)>,
Vec<tokio::sync::OwnedMutexGuard<()>>,
)> = if let Some(active) = branch {
let touched: Vec<(String, Option<String>)> = node_rows
.keys()
.map(|t| (format!("node:{t}"), Some(active.to_string())))
.chain(
edge_rows
.keys()
.map(|e| (format!("edge:{e}"), Some(active.to_string()))),
)
.collect();
let needs_fork = touched.iter().any(|(table_key, _)| {
snapshot
.entry(table_key)
.map(|e| e.table_branch.as_deref() != Some(active))
.unwrap_or(false)
});
if needs_fork {
let guards = db.write_queue().acquire_many(&touched).await;
Some((touched, guards))
} else {
None
}
} else {
None
};
// Phase 2a: build and validate every node batch up front. Cheap and
// synchronous — surfaces validation errors before any S3 traffic.
let mut prepared_nodes: Vec<(String, String, RecordBatch, usize)> =
@ -499,6 +500,7 @@ async fn load_jsonl_reader<R: BufRead>(
&table_key,
opened.full_path,
opened.table_branch,
opened.deferred_fork,
opened.expected_version,
load_op_kind,
);
@ -534,6 +536,7 @@ async fn load_jsonl_reader<R: BufRead>(
&table_key,
opened.full_path,
opened.table_branch,
opened.deferred_fork,
opened.expected_version,
load_op_kind,
);
@ -581,43 +584,60 @@ async fn load_jsonl_reader<R: BufRead>(
let staged = staging
.stage_all_with_concurrency(db, branch, load_write_concurrency())
.await?;
// `_queue_guards` holds per-(table_key, branch) write queues
// across the manifest publish below — see exec/mutation.rs for
// the rationale (interleaving prevention).
crate::failpoints::maybe_fail(crate::failpoints::names::MUTATION_POST_STAGE_PRE_EFFECT_GATE)?;
let lineage_intent = db.new_lineage_intent_for_branch(branch, actor_id).await?;
// `_queue_guards` holds the root-shared schema → branch → sorted-table
// gates across manifest publication. This closes same-process
// interleaving across the v3 sidecar/effect lifetime. The exact publisher
// token and durable sidecar remain persistent correctness authorities, but
// these local gates do not expand the documented single-writer-process
// recovery boundary.
let crate::exec::staging::CommittedMutation {
updates,
expected_versions,
sidecar_handle,
guards: _queue_guards,
committed_handles,
} = staged
.commit_all(
db,
branch,
crate::db::manifest::SidecarKind::Load,
actor_id,
fork_queue_guards,
Some(&txn),
&txn,
&lineage_intent,
)
.await?;
// Same finalize → publisher residual as mutations: per-table
// staged commits have advanced Lance HEAD, but the manifest
// publish has not run yet. Reuse the mutation failpoint name so
// one failpoint pins the shared `MutationStaging` boundary.
// Same confirmed-effects → publisher boundary as mutations: table HEADs
// have advanced and the v3 sidecar contains their exact transaction
// identities, but the graph manifest has not published the result. Reuse
// the mutation failpoint name so one failpoint pins the shared boundary.
crate::failpoints::maybe_fail(crate::failpoints::names::MUTATION_POST_FINALIZE_PRE_PUBLISHER)?;
db.commit_updates_on_branch_with_expected(
branch,
&updates,
&expected_versions,
actor_id,
Some(&txn),
committed_handles,
)
.await?;
// The recovery sidecar protects the per-table commit_staged →
// manifest publish window. Phase C succeeded — clean up
// best-effort: failing the user here would error out a write
// that already landed durably.
let publish_result = db
.commit_updates_on_branch_with_expected(
branch,
&updates,
&expected_versions,
actor_id,
&txn,
lineage_intent,
)
.await;
if let Err(err) = publish_result {
// Empty loads can still publish lineage but have no table effect and
// therefore no recovery sidecar. Preserve that publish error instead
// of manufacturing an "unknown" recovery operation.
return match sidecar_handle.as_ref() {
Some(handle) => Err(OmniError::recovery_required(
handle.operation_id.clone(),
err.to_string(),
)),
None => Err(err),
};
}
// The v3 recovery sidecar protects every independently durable table effect
// through the one manifest visibility point. Phase C succeeded — clean up
// best-effort: failing the user here would error out a write that already
// landed durably; a leftover fixed outcome is idempotently finalized later.
if let Some(handle) = sidecar_handle {
if let Err(err) = crate::db::manifest::delete_sidecar(&handle, db.storage_adapter()).await {
tracing::warn!(
@ -1843,7 +1863,10 @@ edge WorksAt: Person -> Company
let result = db
.load_as("nonexistent", None, TEST_DATA, LoadMode::Merge, None)
.await;
assert!(result.is_err(), "load without base must not create branches");
assert!(
result.is_err(),
"load without base must not create branches"
);
assert!(
!db.branch_list()
.await
@ -1853,7 +1876,10 @@ edge WorksAt: Person -> Company
);
// Loads to main carry the default branch metadata.
let main_load = db.load("main", TEST_DATA, LoadMode::Overwrite).await.unwrap();
let main_load = db
.load("main", TEST_DATA, LoadMode::Overwrite)
.await
.unwrap();
assert_eq!(main_load.branch, "main");
assert_eq!(main_load.base_branch, None);
assert!(!main_load.branch_created);

View file

@ -502,6 +502,54 @@ pub fn normalize_root_uri(uri: &str) -> Result<String> {
}
}
/// Process-local identity used to share writer queues across handles for the
/// same graph root.
///
/// The storage URI remains unchanged: this identity is used only as the key in
/// the in-process queue registry. Local paths are first made absolute
/// lexically, then the deepest canonicalizable ancestor is resolved so aliases
/// through symlinks converge. Any suffix that does not exist yet is appended
/// unchanged, which makes the identity safe to compute before `init` creates
/// the graph directory. Object-store and caller-defined URI schemes are opaque
/// and retain their normalized spelling.
pub(crate) fn write_queue_root_identity(normalized_root: &str) -> Result<String> {
let local_path = if normalized_root.starts_with(FILE_SCHEME_PREFIX) {
local_path_from_file_uri(normalized_root)?
} else if Path::new(normalized_root).is_absolute() {
PathBuf::from(normalized_root)
} else if has_uri_scheme(normalized_root) {
return Ok(normalized_root.to_string());
} else {
PathBuf::from(normalized_root)
};
let absolute = absolutize_lexically(local_path)?;
let mut ancestor = absolute.as_path();
let mut suffix = Vec::new();
loop {
if let Ok(canonical) = std::fs::canonicalize(ancestor) {
let mut identity = canonical;
for component in suffix.iter().rev() {
identity.push(component);
}
return Ok(normalize_local_path(&identity));
}
let Some(name) = ancestor.file_name() else {
// Filesystem roots should always canonicalize. Falling back to the
// lexical absolute path preserves queue availability on unusual
// platforms/filesystems without changing the storage root.
return Ok(normalize_local_path(&absolute));
};
suffix.push(name.to_os_string());
let Some(parent) = ancestor.parent() else {
return Ok(normalize_local_path(&absolute));
};
ancestor = parent;
}
}
pub fn join_uri(root_uri: &str, relative_path: &str) -> String {
let relative_path = relative_path.trim_start_matches('/');
match storage_kind_for_uri(root_uri) {
@ -534,6 +582,18 @@ fn local_path_from_uri(uri: &str) -> Result<PathBuf> {
Ok(PathBuf::from(uri))
}
fn has_uri_scheme(value: &str) -> bool {
let Some(colon) = value.find(':') else {
return false;
};
let scheme = &value[..colon];
!scheme.is_empty()
&& scheme.as_bytes()[0].is_ascii_alphabetic()
&& scheme
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
}
/// Lexically absolutize a local path: join relative paths onto the current
/// working directory and fold `.` / `..` components, without touching the
/// filesystem. Required because `object_store::path::Path` rejects
@ -908,6 +968,17 @@ mod tests {
);
}
#[test]
fn write_queue_identity_keeps_remote_and_custom_schemes_opaque() {
for root in [
"s3://bucket/prefix",
"memory://write-queue/custom",
"custom+transport:opaque-root",
] {
assert_eq!(write_queue_root_identity(root).unwrap(), root);
}
}
#[test]
fn join_uri_handles_local_file_and_s3_roots() {
assert_eq!(

View file

@ -66,7 +66,7 @@ use lance::dataset::{WhenMatched, WhenNotMatched};
use crate::db::{Snapshot, SubTableEntry};
use crate::error::Result;
use crate::table_store::{StagedWrite, TableState, TableStore};
use crate::table_store::{StagedTransactionIdentity, StagedWrite, TableState, TableStore};
// ─── sealed module ──────────────────────────────────────────────────────────
@ -174,6 +174,59 @@ impl StagedHandle {
pub(crate) fn into_staged(self) -> StagedWrite {
self.inner
}
/// Lance transaction identity captured when this effect was staged.
pub fn transaction_identity(&self) -> StagedTransactionIdentity {
self.inner.transaction_identity()
}
/// Replace Lance's random transaction UUID with the identity durably armed
/// before a deferred first-touch fork. The read version must still match.
pub(crate) fn bind_transaction_identity(
&mut self,
planned: &StagedTransactionIdentity,
) -> Result<()> {
self.inner.bind_transaction_identity(planned)
}
}
/// Result of the no-conflict-retry commit path used by RFC-022-enrolled
/// writers. `is_exact` checks both transaction identity and achieved version:
/// Lance's initial conflict-resolution pass can preserve `(read_version, uuid)`
/// while committing at a later version. The table effect is durable when that
/// happens, so the caller must leave its recovery sidecar armed.
#[derive(Debug)]
pub struct ExactCommitOutcome {
snapshot: SnapshotHandle,
planned_transaction: StagedTransactionIdentity,
committed_transaction: StagedTransactionIdentity,
}
impl ExactCommitOutcome {
pub fn is_exact(&self) -> bool {
self.planned_transaction == self.committed_transaction
&& self.snapshot.version() == self.planned_transaction.read_version + 1
}
pub fn planned_transaction(&self) -> &StagedTransactionIdentity {
&self.planned_transaction
}
pub fn committed_transaction(&self) -> &StagedTransactionIdentity {
&self.committed_transaction
}
pub fn committed_version(&self) -> u64 {
self.snapshot.version()
}
pub fn snapshot(&self) -> &SnapshotHandle {
&self.snapshot
}
pub fn into_snapshot(self) -> SnapshotHandle {
self.snapshot
}
}
/// Helper: clone the inner `StagedWrite` out of each `StagedHandle` and
@ -324,6 +377,20 @@ pub trait TableStorage: sealed::Sealed + Send + Sync + Debug {
key_column: Option<&str>,
) -> Result<Vec<RecordBatch>>;
/// Full-schema blob-aware sibling of `scan_with_pending` for mutation
/// updates. The committed predicate scan retains row ids without projecting
/// blobs; only matched rows are then taken and rebuilt as Lance's logical
/// blob input arrays before unioning the in-memory pending view. This keeps
/// the eventual merge source schema independent of scalar-index state.
async fn scan_with_pending_materialized_blobs(
&self,
snapshot: &SnapshotHandle,
pending: &[RecordBatch],
pending_schema: Option<SchemaRef>,
filter: Option<&str>,
key_column: Option<&str>,
) -> Result<Vec<RecordBatch>>;
async fn first_row_id_for_filter(
&self,
snapshot: &SnapshotHandle,
@ -366,6 +433,15 @@ pub trait TableStorage: sealed::Sealed + Send + Sync + Debug {
staged: StagedHandle,
) -> Result<SnapshotHandle>;
/// Commit one staged effect with Lance conflict retries disabled and expose
/// the transaction identity that actually landed. Legacy callers retain
/// `commit_staged`; RFC-022 adapters opt into this method explicitly.
async fn commit_staged_exact(
&self,
snapshot: SnapshotHandle,
staged: StagedHandle,
) -> Result<ExactCommitOutcome>;
/// Stage an overwrite (Operation::Overwrite). MR-793 Phase 2.
async fn stage_overwrite(
&self,
@ -636,6 +712,25 @@ impl TableStorage for TableStore {
.await
}
async fn scan_with_pending_materialized_blobs(
&self,
snapshot: &SnapshotHandle,
pending: &[RecordBatch],
pending_schema: Option<SchemaRef>,
filter: Option<&str>,
key_column: Option<&str>,
) -> Result<Vec<RecordBatch>> {
TableStore::scan_with_pending_materialized_blobs(
self,
snapshot.dataset(),
pending,
pending_schema,
filter,
key_column,
)
.await
}
async fn first_row_id_for_filter(
&self,
snapshot: &SnapshotHandle,
@ -701,6 +796,22 @@ impl TableStorage for TableStore {
.map(SnapshotHandle::new)
}
async fn commit_staged_exact(
&self,
snapshot: SnapshotHandle,
staged: StagedHandle,
) -> Result<ExactCommitOutcome> {
let planned_transaction = staged.transaction_identity();
let ds_arc = snapshot.into_arc();
let (dataset, committed_transaction) =
TableStore::commit_staged_exact(self, ds_arc, staged.into_staged()).await?;
Ok(ExactCommitOutcome {
snapshot: SnapshotHandle::new(dataset),
planned_transaction,
committed_transaction,
})
}
async fn stage_overwrite(
&self,
snapshot: &SnapshotHandle,

View file

@ -16,13 +16,14 @@ use lance::dataset::{
use lance::datatypes::{BlobKind, Schema as LanceSchema};
use lance::index::DatasetIndexExt;
use lance::index::scalar::IndexDetails;
use lance_select::mask::RowAddrTreeMap;
use lance_file::version::LanceFileVersion;
use lance_index::scalar::{InvertedIndexParams, ScalarIndexParams};
use lance_index::{IndexType, is_system_index};
use lance_linalg::distance::MetricType;
use lance_select::mask::RowAddrTreeMap;
use lance_table::format::{Fragment, IndexMetadata, RowIdMeta};
use lance_table::rowids::{RowIdSequence, write_row_ids};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::db::manifest::TableVersionMetadata;
@ -50,6 +51,30 @@ pub enum IndexCoverage {
Degraded { reason: String },
}
/// Stable identity of one Lance transaction.
///
/// Lance persists both fields in the transaction file referenced by the
/// committed manifest. Recovery uses the pair, rather than a numeric table
/// version alone, to prove that an observed HEAD was produced by the staged
/// effect named in a recovery sidecar. The UUID distinguishes two writers that
/// started from the same version. Lance may preserve both fields while
/// rebasing, so enrolled callers must also require the achieved table version
/// to be exactly `read_version + 1`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StagedTransactionIdentity {
pub read_version: u64,
pub uuid: String,
}
impl From<&Transaction> for StagedTransactionIdentity {
fn from(transaction: &Transaction) -> Self {
Self {
read_version: transaction.read_version,
uuid: transaction.uuid.clone(),
}
}
}
/// A Lance write that has produced fragment files on object storage but is
/// not yet committed to the dataset's manifest. The staged-write primitives
/// are consumed by `MutationStaging` (`exec/staging.rs`,
@ -137,6 +162,30 @@ impl StagedWrite {
pub fn removed_fragment_ids(&self) -> &[u64] {
&self.removed_fragment_ids
}
/// Identity Lance assigned when this effect was staged.
pub fn transaction_identity(&self) -> StagedTransactionIdentity {
StagedTransactionIdentity::from(&self.transaction)
}
/// Bind a pre-minted recovery identity to a transaction staged after a
/// deferred branch fork. The operation and read version still come from
/// Lance; only its otherwise-random UUID is replaced so the sidecar can be
/// durable before the target ref (and its branch-local fragment paths)
/// exist.
pub(crate) fn bind_transaction_identity(
&mut self,
planned: &StagedTransactionIdentity,
) -> Result<()> {
if self.transaction.read_version != planned.read_version {
return Err(OmniError::manifest_internal(format!(
"staged transaction read version {} does not match pre-minted recovery pin {}",
self.transaction.read_version, planned.read_version
)));
}
self.transaction.uuid.clone_from(&planned.uuid);
Ok(())
}
}
#[derive(Debug, Clone)]
@ -376,6 +425,11 @@ impl TableStore {
return Err(OmniError::Lance(create_err.to_string()));
}
// The ref is now independently durable. Any error from this point is an
// ambiguous/post-effect outcome to the caller and must retain an armed
// recovery intent rather than being treated as a safe pre-effect retry.
crate::failpoints::maybe_fail(crate::failpoints::names::FORK_POST_CREATE_PRE_OPEN)?;
let ds = self
.open_dataset_head(dataset_uri, Some(target_branch))
.await?;
@ -446,6 +500,30 @@ impl TableStore {
.copied()
.collect::<Vec<_>>();
Self::materialize_blob_batch_with_row_ids(ds, batch, &row_ids).await
}
/// Rebuild the blob columns in `batch` using explicit stable row ids.
///
/// Most rewrite callers scan with `_rowid` and use
/// [`Self::materialize_blob_batch`]. A predicate-filtered blob mutation
/// cannot include blob descriptors in that scan on the pinned Lance
/// revision (the filter projection panics), so it first scans only
/// non-blob columns + `_rowid`, takes the full descriptor rows by id, and
/// calls this sibling with the ids captured by the safe scan.
async fn materialize_blob_batch_with_row_ids(
ds: &Dataset,
batch: RecordBatch,
row_ids: &[u64],
) -> Result<RecordBatch> {
if batch.num_rows() != row_ids.len() {
return Err(OmniError::Lance(format!(
"blob materialization row count {} does not match {} row ids",
batch.num_rows(),
row_ids.len()
)));
}
let schema: SchemaRef = Arc::new(ds.schema().into());
let mut columns = Vec::with_capacity(schema.fields().len());
for field in schema.fields() {
@ -1263,6 +1341,44 @@ impl TableStore {
/// the publisher at end-of-query to materialize all staged writes before
/// the meta-manifest commit.
pub async fn commit_staged(&self, ds: Arc<Dataset>, staged: StagedWrite) -> Result<Dataset> {
self.commit_staged_with_retry_budget(ds, staged, None)
.await
.map(|(dataset, _)| dataset)
}
/// Commit an RFC-022-enrolled staged effect with no commit-conflict retry.
///
/// `CommitBuilder::with_max_retries(0)` gives Lance one commit attempt. It
/// can still perform its initial conflict-resolution pass before that
/// attempt, so this method also reads back and returns the identity of the
/// transaction that actually landed. Callers must compare it with
/// [`StagedWrite::transaction_identity`] AND require the returned dataset
/// version to equal `read_version + 1`: Lance's preflight rebase can
/// preserve the transaction fields while committing at a later version.
/// Either mismatch is a post-effect recovery case, not permission to widen
/// the prepared plan.
pub async fn commit_staged_exact(
&self,
ds: Arc<Dataset>,
staged: StagedWrite,
) -> Result<(Dataset, StagedTransactionIdentity)> {
let (dataset, committed_identity) = self
.commit_staged_with_retry_budget(ds, staged, Some(0))
.await?;
let committed_identity = committed_identity.ok_or_else(|| {
OmniError::manifest_internal(
"Lance committed a staged effect without a readable transaction identity",
)
})?;
Ok((dataset, committed_identity))
}
async fn commit_staged_with_retry_budget(
&self,
ds: Arc<Dataset>,
staged: StagedWrite,
max_retries: Option<u32>,
) -> Result<(Dataset, Option<StagedTransactionIdentity>)> {
// Skip Lance's auto-cleanup hook on every commit. OmniGraph owns version
// GC explicitly (optimize.rs::cleanup_all_tables); Lance's hook fires off
// the *dataset's stored* `lance.auto_cleanup.*` config, which graphs
@ -1272,13 +1388,27 @@ impl TableStore {
// data path) for new and legacy datasets alike, preventing Lance from
// GC'ing versions the __manifest still pins for snapshots/time-travel.
let mut builder = CommitBuilder::new(ds).with_skip_auto_cleanup(true);
if let Some(max_retries) = max_retries {
builder = builder.with_max_retries(max_retries);
}
if let Some(affected_rows) = staged.commit_metadata.affected_rows {
builder = builder.with_affected_rows(affected_rows);
}
builder
let dataset = builder
.execute(staged.transaction)
.await
.map_err(|e| OmniError::Lance(e.to_string()))
.map_err(|e| OmniError::Lance(e.to_string()))?;
let committed_identity = if max_retries.is_some() {
dataset
.read_transaction()
.await
.map_err(|e| OmniError::Lance(e.to_string()))?
.as_ref()
.map(StagedTransactionIdentity::from)
} else {
None
};
Ok((dataset, committed_identity))
}
/// Stage an overwrite (write_fragments + Operation::Overwrite { schema, fragments }).
@ -1581,33 +1711,119 @@ impl TableStore {
}
let committed = self.scan(committed_ds, projection, filter, None).await?;
if pending_batches.is_empty() {
return Ok(committed);
combine_committed_with_pending(
committed,
pending_batches,
pending_schema,
projection,
filter,
key_column,
)
.await
}
/// Read a blob-bearing table as a full logical merge source while retaining
/// [`Self::scan_with_pending`]'s merge-shadow semantics.
///
/// Lance normally scans blob-v2 columns as physical descriptor structs.
/// Those descriptors are a read representation, not valid writer input: a
/// full-row merge sends them through the blob writer, which requires the
/// logical `Struct<data, uri>` shape and fails with `Blob struct missing
/// data field`. This remained hidden while an `id` BTREE forced Lance's
/// partial-column merge plan; once index creation became reconciler-owned,
/// an index-absent table correctly selected the full-scan plan and exposed
/// the representation mismatch.
///
/// A first scan evaluates `filter` with blob columns excluded and retains
/// stable row ids. Full descriptor rows are then taken by those ids without
/// a filter, and only their payloads are rebuilt as logical
/// [`BlobArrayBuilder`] columns. Pending rows already carry logical blob
/// arrays; both sides have the dataset's full logical schema before the
/// existing shadow union. The resulting batch is therefore valid for either
/// of Lance's merge plans, making physical index presence a performance
/// detail rather than a correctness precondition.
pub async fn scan_with_pending_materialized_blobs(
&self,
committed_ds: &Dataset,
pending_batches: &[RecordBatch],
pending_schema: Option<SchemaRef>,
filter: Option<&str>,
key_column: Option<&str>,
) -> Result<Vec<RecordBatch>> {
let blob_columns = committed_ds
.schema()
.fields
.iter()
.filter(|field| field.is_blob())
.map(|field| field.name.clone())
.collect::<Vec<_>>();
if blob_columns.is_empty() {
return self
.scan_with_pending(
committed_ds,
pending_batches,
pending_schema,
None,
filter,
key_column,
)
.await;
}
// Shadow committed rows whose key value also appears in pending.
// This makes scan_with_pending implement merge semantics rather
// than naive union: any row that has a pending update is
// represented ONLY by its pending value, never by both its
// (stale) committed value and its (current) pending value.
let committed = match key_column {
Some(key_col) => {
let pending_keys = collect_string_column_values(pending_batches, key_col)?;
if pending_keys.is_empty() {
committed
} else {
filter_out_rows_where_string_in(committed, key_col, &pending_keys)?
}
// The pinned Lance revision cannot combine a predicate filter with a
// full blob-v2 projection: `FilteredReadExec` applies the descriptor
// child projection to the logical blob field and panics. Select the
// matched rows without blob columns, retaining stable `_rowid`, then
// take exactly those full descriptor rows without a filter and rebuild
// their logical blobs. Thus payload I/O remains proportional to matched
// rows while avoiding any dependence on which merge/index plan Lance
// selects later.
let non_blob_columns = committed_ds
.schema()
.fields
.iter()
.filter(|field| !field.is_blob())
.map(|field| field.name.as_str())
.collect::<Vec<_>>();
let matched = Self::scan_stream(committed_ds, Some(&non_blob_columns), filter, None, true)
.await?
.try_collect::<Vec<RecordBatch>>()
.await
.map_err(|error| OmniError::Lance(error.to_string()))?;
let mut committed = Vec::with_capacity(matched.len());
for batch in matched {
let row_ids = batch
.column_by_name("_rowid")
.and_then(|column| column.as_any().downcast_ref::<UInt64Array>())
.ok_or_else(|| {
OmniError::Lance("expected _rowid in predicate-matched blob scan".to_string())
})?
.values()
.to_vec();
if row_ids.is_empty() {
continue;
}
None => committed,
};
let descriptors = committed_ds
.take_rows(&row_ids, committed_ds.schema().clone())
.await
.map_err(|error| OmniError::Lance(error.to_string()))?;
committed.push(
Self::materialize_blob_batch_with_row_ids(committed_ds, descriptors, &row_ids)
.await?,
);
}
let pending =
scan_pending_batches(pending_batches, pending_schema, projection, filter).await?;
let mut out = committed;
out.extend(pending);
Ok(out)
let combined = combine_committed_with_pending(
committed,
pending_batches,
pending_schema,
None,
filter,
key_column,
)
.await?;
Ok(combined)
}
/// `count_rows` variant that respects staged writes. Used for
@ -1844,6 +2060,42 @@ fn assign_row_id_meta(fragments: &mut [Fragment], start_row_id: u64) -> Result<(
Ok(())
}
/// Apply the in-memory half of `scan_with_pending` to an already-scanned
/// committed view. Kept as one helper so the ordinary descriptor scan and the
/// blob-materializing scan cannot drift in their key-shadow semantics.
async fn combine_committed_with_pending(
committed: Vec<RecordBatch>,
pending_batches: &[RecordBatch],
pending_schema: Option<SchemaRef>,
projection: Option<&[&str]>,
filter: Option<&str>,
key_column: Option<&str>,
) -> Result<Vec<RecordBatch>> {
if pending_batches.is_empty() {
return Ok(committed);
}
// Shadow committed rows whose key value also appears in pending. This is
// deliberately applied before filtering pending: a pending row that no
// longer matches must still suppress its stale committed predecessor.
let committed = match key_column {
Some(key_col) => {
let pending_keys = collect_string_column_values(pending_batches, key_col)?;
if pending_keys.is_empty() {
committed
} else {
filter_out_rows_where_string_in(committed, key_col, &pending_keys)?
}
}
None => committed,
};
let pending = scan_pending_batches(pending_batches, pending_schema, projection, filter).await?;
let mut out = committed;
out.extend(pending);
Ok(out)
}
/// Collect the set of values in a Utf8 column across multiple batches.
/// Used by `scan_with_pending`'s merge-semantic path to identify
/// committed rows that are shadowed by pending writes. NULL values are

View file

@ -238,9 +238,10 @@ pub(crate) struct CommittedState<'a> {
/// tables, not a global flag — an edges-only overwrite still sees committed
/// nodes for RI. Empty on the merge / mutation / append / merge-load paths.
overwritten: HashSet<String>,
/// Write path only: open edge tables at LIVE HEAD for `@card` (the #298
/// stale-handle fix). `None` on the merge and load paths — there the snapshot
/// (or empty) is the committed view for every check.
/// Write path only: open edge tables from a fresh graph-branch manifest
/// snapshot for `@card` (the #298 stale-handle fix). This is the live
/// committed graph view, not a raw Lance HEAD that may be unpublished or
/// belong to an inherited source ref. `None` on merge/load.
live: Option<(&'a Omnigraph, Option<&'a str>)>,
}
@ -255,7 +256,8 @@ impl<'a> CommittedState<'a> {
}
/// Write path: existence/uniqueness read `committed` (the write's pinned
/// base); cardinality reads LIVE HEAD per edge table via `db` (#298).
/// base); cardinality reads the live committed branch snapshot via `db`
/// (#298).
pub(crate) fn write(committed: &'a Snapshot, db: &'a Omnigraph, branch: Option<&'a str>) -> Self {
Self {
committed: Some(committed),
@ -300,10 +302,12 @@ impl<'a> CommittedState<'a> {
}
}
/// Open an edge table for cardinality counting: LIVE HEAD on the write path
/// (so an edge a concurrent writer committed since the base was pinned is
/// counted — #298), the committed snapshot otherwise. `None` if there is no
/// committed view (Overwrite load) or the table isn't in it.
/// Open an edge table for cardinality counting: the current manifest-visible
/// graph-branch snapshot on the write path (so a concurrent published edge
/// is counted — #298), the pinned committed snapshot otherwise. Resolving
/// through the fresh graph snapshot is load-bearing for first-touch named
/// branches: their table still inherits another Lance ref until this write's
/// sidecar is armed, so opening the target ref directly would be invalid.
async fn open_cardinality(&self, table_key: &str) -> Result<Option<Dataset>> {
if self.overwritten.contains(table_key) {
return Ok(None);
@ -311,14 +315,21 @@ impl<'a> CommittedState<'a> {
let Some(committed) = self.committed else {
return Ok(None);
};
let Some(entry) = committed.entry(table_key) else {
let Some(_entry) = committed.entry(table_key) else {
return Ok(None);
};
match self.live {
Some((db, branch)) => {
let full_path = db.storage().dataset_uri(&entry.table_path);
let handle = db.storage().open_dataset_head(&full_path, branch).await?;
Ok(Some(handle.dataset().clone()))
// `CommittedState::write` is constructed only after WriteTxn
// schema validation, so use the unchecked manifest refresh to
// avoid another full contract read while retaining live branch
// authority. Snapshot::open follows the entry's actual
// `table_branch` and pinned version (including inheritance).
let live = db.fresh_snapshot_for_branch_unchecked(branch).await?;
match live.entry(table_key) {
Some(_) => Ok(Some(live.open(table_key).await?)),
None => Ok(None),
}
}
None => Ok(Some(committed.open(table_key).await?)),
}

View file

@ -351,6 +351,22 @@ async fn branch_merge_with_blob_columns_preserves_blob_data() {
)
.await
.unwrap();
// This regression must not rely on an incidental physical index selecting
// Lance's legacy partial-column merge plan. The materialized-blob update
// path is correct even when the table has no user index at all.
let ds = snapshot_main(&main)
.await
.unwrap()
.open("node:Document")
.await
.unwrap();
let indices = ds.load_indices().await.unwrap();
assert!(
indices.iter().all(is_system_index),
"blob correctness regression requires an index-absent table"
);
main.branch_create("feature").await.unwrap();
let mut feature = Omnigraph::open(uri).await.unwrap();
@ -629,7 +645,10 @@ async fn same_branch_insert_after_external_commit_is_linear() {
.iter()
.filter(|c| c.parent_commit_id.as_deref() == Some(c0.graph_commit_id.as_str()))
.count();
assert_eq!(c0_children, 1, "C0 must have exactly one child; two is the fork");
assert_eq!(
c0_children, 1,
"C0 must have exactly one child; two is the fork"
);
}
/// Strict update after a read: Fix 1's `refresh_manifest_only` makes the read
@ -676,7 +695,10 @@ async fn same_branch_update_after_external_commit_and_read_is_linear() {
.await
.unwrap()
.unwrap();
assert_eq!(cb.parent_commit_id.as_deref(), Some(ca.graph_commit_id.as_str()));
assert_eq!(
cb.parent_commit_id.as_deref(),
Some(ca.graph_commit_id.as_str())
);
// A reads main: the stale-probe path refreshes A's MANIFEST (via
// refresh_manifest_only) but not its commit-graph head, freshening the
@ -713,7 +735,10 @@ async fn same_branch_update_after_external_commit_and_read_is_linear() {
.iter()
.filter(|c| c.parent_commit_id.as_deref() == Some(ca.graph_commit_id.as_str()))
.count();
assert_eq!(ca_children, 1, "Ca must have exactly one child; two is the fork");
assert_eq!(
ca_children, 1,
"Ca must have exactly one child; two is the fork"
);
}
#[tokio::test]

View file

@ -815,16 +815,15 @@ query high_value() {
assert_eq!(values.value(8), 499);
}
// ─── Stale handle must refresh-and-retry (no silent rebase) ──────────────
// ─── Long-lived strict handle refreshes before preparation ───────────────
#[tokio::test]
async fn stale_handle_public_mutation_must_refresh_then_retry() {
// With the Run state machine removed, the engine no longer
// auto-rebases stale-handle mutations onto the latest target head.
// The publisher's `expected_table_versions` CAS makes the contract
// explicit — a stale writer fails loudly with
// `ExpectedVersionMismatch` and the client decides whether to
// refresh-and-retry.
async fn long_lived_handle_prepares_strict_mutation_from_current_head() {
// Merely opening a handle before another completed commit does not make the
// next attempt stale: open_write_txn probes the manifest incarnation and
// captures the current branch authority before it plans the strict update.
// ReadSetChanged is reserved for movement during an already-prepared
// attempt (covered deterministically in the failpoint suite).
let dir = tempfile::tempdir().unwrap();
let _db = init_and_load(&dir).await;
drop(_db);
@ -843,26 +842,8 @@ async fn stale_handle_public_mutation_must_refresh_then_retry() {
.await
.unwrap();
// Writer 2 is now stale. Its first attempt must fail with
// ExpectedVersionMismatch — no silent rebase.
let stale_err = mutate_main(
&mut db2,
MUTATION_QUERIES,
"set_age",
&mixed_params(&[("$name", "Alice")], &[("$age", 99)]),
)
.await
.expect_err("stale writer must hit ExpectedVersionMismatch");
let omnigraph::error::OmniError::Manifest(manifest_err) = stale_err else {
panic!("expected Manifest error");
};
assert!(matches!(
manifest_err.details,
Some(omnigraph::error::ManifestConflictDetails::ExpectedVersionMismatch { .. })
));
// Refresh and retry — the canonical client recovery path.
db2.sync_branch("main").await.unwrap();
// Writer 2's handle predates Eve, but its strict attempt prepares from the
// current head and succeeds in one call.
mutate_main(
&mut db2,
MUTATION_QUERIES,

File diff suppressed because it is too large Load diff

View file

@ -202,7 +202,8 @@ impl ObjectStore for PrefixCountingStore {
}
fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
self.counter.record_read(&prefix.cloned().unwrap_or_default());
self.counter
.record_read(&prefix.cloned().unwrap_or_default());
self.target.list(prefix)
}
@ -211,12 +212,14 @@ impl ObjectStore for PrefixCountingStore {
prefix: Option<&Path>,
offset: &Path,
) -> BoxStream<'static, OSResult<ObjectMeta>> {
self.counter.record_read(&prefix.cloned().unwrap_or_default());
self.counter
.record_read(&prefix.cloned().unwrap_or_default());
self.target.list_with_offset(prefix, offset)
}
async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
self.counter.record_read(&prefix.cloned().unwrap_or_default());
self.counter
.record_read(&prefix.cloned().unwrap_or_default());
self.target.list_with_delimiter(prefix).await
}

View file

@ -61,6 +61,9 @@ pub async fn init_and_load(dir: &tempfile::TempDir) -> Omnigraph {
load_jsonl(&mut db, TEST_DATA, LoadMode::Overwrite)
.await
.unwrap();
// Mutation/load publish only exact data effects; physical indexes are
// reconciled separately as derived state.
db.ensure_indices().await.unwrap();
db
}
@ -211,15 +214,18 @@ pub async fn commit_many(db: &mut Omnigraph, n: usize) {
}
}
/// Like [`commit_many`] but every commit carries an actor, so it grows
/// `_graph_commit_actors.lance` too — the authenticated (server/CLI) write path.
/// Like [`commit_many`] but every commit carries an actor in its inline
/// `__manifest` lineage row — the authenticated (server/CLI) write path.
pub async fn commit_many_as(db: &mut Omnigraph, n: usize, actor: &str) {
for i in 0..n {
db.mutate_as(
"main",
MUTATION_QUERIES,
"insert_person",
&mixed_params(&[("$name", &format!("commit_many_as_{i}"))], &[("$age", 30)]),
&mixed_params(
&[("$name", &format!("commit_many_as_{i}"))],
&[("$age", 30)],
),
Some(actor),
)
.await

View file

@ -13,8 +13,17 @@ const RECOVERY_ACTOR: &str = "omnigraph:recovery";
#[derive(Debug)]
pub enum RecoveryExpectation {
RolledForward { tables: Vec<TableExpectation> },
RolledBack { tables: Vec<TableExpectation> },
RolledForward {
tables: Vec<TableExpectation>,
},
/// Protocol-v3 mutation/load recovery republishes the writer's fixed
/// lineage intent rather than minting a synthetic recovery commit.
RolledForwardOriginalLineage {
tables: Vec<TableExpectation>,
},
RolledBack {
tables: Vec<TableExpectation>,
},
Deferred,
NoOp,
}
@ -41,6 +50,7 @@ pub struct FollowUpMutation {
struct RecoveryAuditRow {
graph_commit_id: String,
recovery_kind: String,
recovery_for_actor: Option<String>,
operation_id: String,
sidecar_writer_kind: String,
per_table_outcomes: Vec<TableOutcome>,
@ -201,7 +211,21 @@ pub async fn assert_post_recovery_invariants(
);
assert_manifest_pins_match_lance_heads(graph_root, &tables).await?;
assert_audit_to_versions_match_lance_heads(graph_root, &audit, &tables).await?;
assert_recovery_commit_shape(graph_root, &audit, &tables).await?;
assert_recovery_commit_shape(graph_root, &audit, &tables, false).await?;
assert_non_main_did_not_move_main(graph_root, &tables).await?;
assert_idempotent_reopen(graph_root, operation_id).await?;
run_follow_up_mutations(graph_root, tables).await?;
}
RecoveryExpectation::RolledForwardOriginalLineage { tables } => {
assert_sidecar_absent(graph_root, operation_id);
let audit = read_audit_row(graph_root, operation_id).await?;
assert_eq!(
audit.recovery_kind, "RolledForward",
"audit row for {operation_id} recorded the wrong recovery_kind",
);
assert_manifest_pins_match_lance_heads(graph_root, &tables).await?;
assert_audit_to_versions_match_lance_heads(graph_root, &audit, &tables).await?;
assert_recovery_commit_shape(graph_root, &audit, &tables, true).await?;
assert_non_main_did_not_move_main(graph_root, &tables).await?;
assert_idempotent_reopen(graph_root, operation_id).await?;
run_follow_up_mutations(graph_root, tables).await?;
@ -217,7 +241,7 @@ pub async fn assert_post_recovery_invariants(
// Roll-back now publishes the restored HEAD, so manifest == Lance
// HEAD afterward (symmetric with roll-forward) — no residual drift.
assert_manifest_pins_match_lance_heads(graph_root, &tables).await?;
assert_recovery_commit_shape(graph_root, &audit, &tables).await?;
assert_recovery_commit_shape(graph_root, &audit, &tables, false).await?;
assert_non_main_did_not_move_main(graph_root, &tables).await?;
assert_idempotent_reopen(graph_root, operation_id).await?;
run_follow_up_mutations(graph_root, tables).await?;
@ -366,19 +390,29 @@ async fn assert_recovery_commit_shape(
graph_root: &Path,
audit: &RecoveryAuditRow,
tables: &[TableExpectation],
preserves_original_intent: bool,
) -> Result<()> {
let branch = branch_context(tables);
let expected_parent = expected_recovery_parent(tables)?;
let branch = branch.as_deref();
let commit = read_recovery_commit(graph_root, audit, branch).await?;
// RFC-022 mutation/load roll-forward publishes the writer's original,
// pre-minted lineage intent. That preserves its original actor (including
// `None`) and makes a crash after publish distinguishable from a new
// synthetic recovery content commit. Rollback and legacy writer recovery
// still publish an explicit `omnigraph:recovery` commit.
let expected_actor = if preserves_original_intent {
audit.recovery_for_actor.as_deref()
} else {
Some(RECOVERY_ACTOR)
};
assert_eq!(
commit.actor_id.as_deref(),
Some(RECOVERY_ACTOR),
"recovery commit {} for operation {} must use actor {}",
expected_actor,
"recovery commit {} for operation {} recorded the wrong actor",
commit.graph_commit_id,
audit.operation_id,
RECOVERY_ACTOR,
);
if let Some(expected_parent) = expected_parent {
@ -573,6 +607,7 @@ async fn matching_audit_rows(
for batch in batches {
let graph_commit_ids = string_column(&batch, "graph_commit_id")?;
let kinds = string_column(&batch, "recovery_kind")?;
let recovery_actors = string_column(&batch, "recovery_for_actor")?;
let ops = string_column(&batch, "operation_id")?;
let writers = string_column(&batch, "sidecar_writer_kind")?;
let outcomes_json = string_column(&batch, "per_table_outcomes_json")?;
@ -589,6 +624,8 @@ async fn matching_audit_rows(
rows.push(RecoveryAuditRow {
graph_commit_id: graph_commit_ids.value(row).to_string(),
recovery_kind: kinds.value(row).to_string(),
recovery_for_actor: (!recovery_actors.is_null(row))
.then(|| recovery_actors.value(row).to_string()),
operation_id: ops.value(row).to_string(),
sidecar_writer_kind: writers.value(row).to_string(),
per_table_outcomes,

View file

@ -1,7 +1,7 @@
//! RFC-013 Phase 7 acceptance gate: graph lineage lives ONLY in `__manifest`.
//!
//! The `graph_commit` + `graph_head` rows ride the same publish CAS as the
//! table-version rows, so `_graph_commits.lance` carries NO commit rows. This
//! table-version rows, so no standalone commit-lineage dataset exists. This
//! gate proves two things over a realistic history (commits on main, a branch,
//! a merge, all with actors):
//!

View file

@ -361,7 +361,8 @@ node Doc {
let uri = dir.path().to_str().unwrap();
let mut db = Omnigraph::init(uri, SCHEMA).await.unwrap();
// First load builds the id + rank BTREEs over the initial fragment.
// Loads publish only data effects; establish the initial id + rank BTREEs
// explicitly through the reconciler before creating partial coverage.
load_jsonl(
&mut db,
"{\"type\":\"Doc\",\"data\":{\"slug\":\"d1\",\"rank\":1}}\n\
@ -370,6 +371,7 @@ node Doc {
)
.await
.unwrap();
db.ensure_indices().await.unwrap();
// A second load with NEW keys appends a fragment the existing BTREEs do not
// cover (the existence gate skips re-building an index that already exists).
@ -411,13 +413,9 @@ node Doc {
);
}
// Regression: `optimize` must not crash on a graph that has a `Blob` table.
//
// Lance `compact_files` forces `BlobHandling::AllBinary`, which mis-decodes
// blob-v2 columns ("more fields in the schema than provided column indices"),
// failing even a pristine uniform-V2_2 multi-fragment blob table. `optimize`
// must skip blob-bearing tables (and report the skip) rather than aborting the
// whole sweep.
// Regression: `optimize` must compact a graph that has a `Blob` table through
// the same positive path as every other data table; a reintroduced skip would
// hide both fragment growth and a Lance compatibility regression.
//
// History: through Lance 7.0.0 `compact_files` mis-decoded blob-v2 columns, so
// `optimize` skipped blob tables (`SkipReason::BlobColumnsUnsupportedByLance`)
@ -431,16 +429,17 @@ node Doc {
async fn optimize_compacts_blob_table_alongside_plain_table() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
// One Blob node type (`Doc`) + one plain node type (`Tag`): proves the blob
// table is skipped while a non-blob table in the same sweep still compacts.
// One Blob node type (`Doc`) + one plain node type (`Tag`): proves both use
// the normal compaction path in the same sweep.
let schema = "\
node Doc {\n slug: String @key\n content: Blob\n}\n\
node Tag {\n slug: String @key\n}\n";
let mut db = Omnigraph::init(uri, schema).await.unwrap();
// Multi-fragment blob table: Overwrite creates fragment 1; each Merge of
// new keys appends another. A >=2-fragment blob table is exactly what
// crashes `compact_files` today (single fragment would no-op and not crash).
// new keys appends another. A >=2-fragment blob table exercises the rewrite
// path that exposed the historical Lance regression (a single fragment
// would be a no-op).
load_jsonl(
&mut db,
"{\"type\":\"Doc\",\"data\":{\"slug\":\"d1\",\"content\":\"base64:aGVsbG8x\"}}\n{\"type\":\"Doc\",\"data\":{\"slug\":\"d2\",\"content\":\"base64:aGVsbG8y\"}}",
@ -1133,15 +1132,12 @@ async fn cleanup_reconciles_live_branch_orphan_fork_but_keeps_legitimate_fork()
}
// Regression (iss-848): a table with rows but NULL vectors (the load-before-
// embed window) must not abort index building. The vector (IVF) index cannot
// train on 0 vectors, so `create_vector_index` errors with "KMeans cannot
// train 1 centroids with 0 vectors". `build_indices_on_dataset_for_catalog`
// is the chokepoint every caller funnels through (load/mutate via
// prepare_updates_for_commit, ensure_indices, optimize, schema apply, merge),
// so per-index fault isolation there must defer that one column (pending) and
// still build the sibling scalar indexes, instead of propagating the error.
// This exercises both the load path (which builds indices inline) and the
// ensure_indices reconciler. Pre-fix this fails at the load step.
// embed window) must remain writable and reconcilable. RFC-022-enrolled writes
// publish only their logical data effect; physical indexes are derived work.
// The vector (IVF) index cannot train on 0 vectors, so the index chokepoint must
// defer that column as pending while still building eligible sibling indexes.
// This exercises both halves of the contract: the logical load succeeds without
// inline index work, then `ensure_indices` tolerates the untrainable vector.
#[tokio::test]
async fn index_build_tolerates_null_vector_rows() {
let dir = tempfile::tempdir().unwrap();

View file

@ -127,8 +127,8 @@ async fn fast_forward_merge_defers_vector_index_to_reconciler() {
let main = Omnigraph::init(uri, VEC_SCHEMA).await.unwrap();
main.branch_create("feature").await.unwrap();
// Load embedding-bearing chunks onto the branch. The branch builds its own
// index here (outside the probe scope) — irrelevant to the merge's cost.
// Load embedding-bearing chunks onto the branch. Load publishes only the
// data effect, so the declared vector index remains pending here too.
let mut rows = String::new();
for i in 0..24 {
let v: Vec<String> = (0..8).map(|j| format!("{}.0", (i + j) % 5)).collect();
@ -140,7 +140,7 @@ async fn fast_forward_merge_defers_vector_index_to_reconciler() {
let feature = Omnigraph::open(uri).await.unwrap();
feature.load("feature", &rows, LoadMode::Merge).await.unwrap();
// Merge, counting inline vector-index builds the publish path performs.
// Merge, asserting that its publish path performs no inline vector-index build.
let probes = MergeWriteProbes::default();
let outcome = with_merge_write_probes(probes.clone(), main.branch_merge("feature", "main"))
.await

View file

@ -1172,12 +1172,10 @@ async fn recovery_ensure_indices_steady_state_no_sidecar() {
/// `count_rows == 0 → return false` short-circuit in `needs_index_work_*`
/// is what makes this work.
///
/// A stronger assertion that captured the sidecar mid-flight and verified
/// the persisted JSON omits empty tables would require bypassing
/// `load_jsonl` (which auto-builds indices via
/// `prepare_updates_for_commit`); pinning that with a unit test on the
/// helpers directly would require bootstrapping an engine plus raw Lance
/// writes — left as a follow-up.
/// A stronger assertion that captured the sidecar after arming but before the
/// first index effect could inspect the persisted pin set directly. That needs
/// a dedicated pre-effect EnsureIndices rendezvous; the current failpoint is
/// after its effects, so this remains an end-to-end behavioral assertion.
#[tokio::test]
async fn recovery_ensure_indices_handles_empty_tables() {
let dir = tempfile::tempdir().unwrap();

View file

@ -30,8 +30,8 @@ const DATA: &str = r#"{"type":"Item","data":{"slug":"a","status":"active","publi
{"type":"Item","data":{"slug":"b","status":"archived","published":"2023-01-01T00:00:00Z","rank":2,"title":"beta","note":"n2"}}
{"type":"Item","data":{"slug":"c","status":"active","published":"2025-02-02T00:00:00Z","rank":3,"title":"gamma","note":"n3"}}"#;
// Enums and orderable scalars (DateTime, numeric) get a BTREE from load's
// build-indices pass, so a `=`/range filter on them uses the index. Free-text
// Enums and orderable scalars (DateTime, numeric) get a BTREE from the index
// reconciler, so a `=`/range filter on them uses the index. Free-text
// String `@index` keeps FTS (no BTREE), and an un-annotated column has no
// scalar index — both report `Degraded`, which is the negative control that
// keeps this test from being vacuously green.
@ -41,6 +41,7 @@ async fn node_scalar_and_enum_index_columns_get_btree() {
let uri = dir.path().to_str().unwrap();
let mut db = Omnigraph::init(uri, SCHEMA).await.unwrap();
load_jsonl(&mut db, DATA, LoadMode::Overwrite).await.unwrap();
db.ensure_indices().await.unwrap();
let snap = snapshot_main(&db).await.unwrap();
let ds = snap.open("node:Item").await.unwrap();

View file

@ -1,14 +1,17 @@
mod helpers;
use std::fs;
#[cfg(feature = "failpoints")]
use std::sync::Arc;
use omnigraph::db::{Omnigraph, ReadTarget};
use omnigraph::db::{MergeOutcome, Omnigraph, ReadTarget};
use omnigraph::loader::{LoadMode, load_jsonl};
use omnigraph_compiler::{SchemaMigrationStep, SchemaTypeKind};
use helpers::*;
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn plan_schema_reports_supported_additive_change() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
@ -33,6 +36,322 @@ async fn plan_schema_reports_supported_additive_change() {
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn long_lived_handle_uses_the_schema_catalog_bound_to_its_write_token() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
let schema_owner = Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
// Open before the migration: this handle's process-local ArcSwap catalog is
// intentionally stale after `schema_owner` completes the apply.
let stale_handle = Omnigraph::open(uri).await.unwrap();
let desired = format!(
"{}\nnode Project {{\n name: String @key\n}}\n",
TEST_SCHEMA.replace(
" age: I32?\n}",
" age: I32?\n nickname: String?\n}",
)
);
schema_owner.apply_schema(&desired).await.unwrap();
let mutation = r#"
query insert_with_nickname($name: String, $age: I32, $nickname: String) {
insert Person { name: $name, age: $age, nickname: $nickname }
}
"#;
let inserted = stale_handle
.mutate(
"main",
mutation,
"insert_with_nickname",
&mixed_params(
&[("$name", "mutated-after-schema"), ("$nickname", "fresh")],
&[("$age", 31)],
),
)
.await
.expect("mutation must typecheck and build its batch with the token-bound catalog");
assert_eq!(inserted.affected_nodes, 1);
let loaded = stale_handle
.load(
"main",
r#"{"type":"Person","data":{"name":"loaded-after-schema","age":32,"nickname":"fresh"}}"#,
LoadMode::Merge,
)
.await
.expect("load parsing and validation must use the same token-bound catalog");
assert_eq!(loaded.nodes_loaded.get("Person"), Some(&1));
assert_eq!(count_rows(&stale_handle, "node:Person").await, 2);
// The same stale handle must bind branch-merge planning and conservative
// branch-control table gates to the accepted contract captured under the
// schema gate. The warm handle catalog predates Project; consulting it here
// would fail with `unknown node type` (or omit Project's control queue).
stale_handle.branch_create("source").await.unwrap();
stale_handle.branch_create("target").await.unwrap();
let project_mutation = r#"
query insert_project($name: String) {
insert Project { name: $name }
}
"#;
stale_handle
.mutate(
"source",
project_mutation,
"insert_project",
&params(&[("$name", "fresh-catalog-project")]),
)
.await
.expect("source write must use the token-bound post-apply catalog");
assert_eq!(
stale_handle
.branch_merge("source", "target")
.await
.expect("merge planning must use the schema-gated post-apply catalog"),
MergeOutcome::FastForward
);
assert_eq!(
count_rows_branch(&stale_handle, "target", "node:Project").await,
1
);
}
/// Native branch controls must enumerate their conservative table envelope
/// from the accepted catalog captured under the schema gate, not a long-lived
/// handle's pre-apply ArcSwap. Park delete after that envelope is held and prove
/// a legacy Project-only index reconciler cannot cross its table queue.
#[cfg(feature = "failpoints")]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial_test::serial]
async fn stale_handle_branch_delete_gates_tables_added_by_schema_apply() {
use omnigraph::failpoints::names;
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
let schema_owner = Omnigraph::init(uri, TEST_SCHEMA).await.unwrap();
let stale_control = Arc::new(Omnigraph::open(uri).await.unwrap());
let desired = format!("{TEST_SCHEMA}\nnode Project {{\n name: String @key\n}}\n");
schema_owner.apply_schema(&desired).await.unwrap();
schema_owner.branch_create("target").await.unwrap();
schema_owner
.load(
"target",
r#"{"type":"Project","data":{"name":"pending-index"}}"#,
LoadMode::Merge,
)
.await
.unwrap();
let index_reconciler = Arc::new(Omnigraph::open(uri).await.unwrap());
let delete_rv =
helpers::failpoint::Rendezvous::park_first(names::BRANCH_DELETE_POST_TABLE_GATES);
let delete_handle = Arc::clone(&stale_control);
let delete_task = tokio::spawn(async move { delete_handle.branch_delete("target").await });
delete_rv.wait_until_reached().await;
let index_handle = Arc::clone(&index_reconciler);
let mut index_task = tokio::spawn(async move { index_handle.ensure_indices_on("target").await });
let index_blocked = tokio::time::timeout(
std::time::Duration::from_millis(250),
&mut index_task,
)
.await
.is_err();
delete_rv.release();
assert!(
index_blocked,
"stale control catalog omitted the newly-added Project table gate"
);
delete_task.await.unwrap().unwrap();
if tokio::time::timeout(std::time::Duration::from_secs(10), &mut index_task)
.await
.is_err()
{
index_task.abort();
let _ = index_task.await;
panic!("index reconciler did not finish after branch delete released its table gate");
}
}
#[cfg(feature = "failpoints")]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial_test::serial]
async fn mutation_waits_for_mid_apply_schema_gate_then_reprepares() {
use omnigraph::failpoints::names;
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(init_and_load(&dir).await);
let desired = TEST_SCHEMA.replace(
" age: I32?\n}",
" age: I32?\n nickname: String?\n}",
);
// First park the mutation after all validation/staging but before it enters
// the schema→branch→table effect gates. This fixes the otherwise tiny race
// window deterministically.
let mutation_rv =
helpers::failpoint::Rendezvous::park_first(names::MUTATION_POST_STAGE_PRE_EFFECT_GATE);
let mutation_db = Arc::clone(&db);
let mutation_task = tokio::spawn(async move {
mutation_db
.mutate(
"main",
MUTATION_QUERIES,
"insert_person",
&mixed_params(&[("$name", "schema-gated")], &[("$age", 33)]),
)
.await
});
mutation_rv.wait_until_reached().await;
// Start schema apply and park it after its staging files (and any table
// rewrite) exist but before manifest/schema promotion. The outer apply owns
// the schema-control gate throughout this window.
let schema_rv =
helpers::failpoint::Rendezvous::park_first(names::SCHEMA_APPLY_AFTER_STAGING_WRITE);
let schema_db = Arc::clone(&db);
let schema_task = tokio::spawn(async move { schema_db.apply_schema(&desired).await });
schema_rv.wait_until_reached().await;
mutation_rv.release();
// Give the already-runnable mutation repeated scheduler turns. It must stay
// pending on the schema gate; completing here means it either advanced under
// an in-flight migration or returned a spurious post-prepare failure.
for _ in 0..128 {
tokio::task::yield_now().await;
if mutation_task.is_finished() {
break;
}
}
assert!(
!mutation_task.is_finished(),
"mutation must remain behind the schema-control gate while apply is in flight",
);
schema_rv.release();
schema_task.await.unwrap().unwrap();
let result = mutation_task
.await
.unwrap()
.expect("insert-only mutation must reprepare under the promoted schema");
assert_eq!(result.affected_nodes, 1);
assert_eq!(count_rows(&db, "node:Person").await, 5);
}
/// ReadOnly opens participate in the process-local schema publication gate even
/// though they perform no recovery writes. Park an open immediately before its
/// source/IR/state read: schema apply must not reach staging until that coherent
/// catalog capture finishes.
#[cfg(feature = "failpoints")]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial_test::serial]
async fn read_only_open_holds_schema_gate_through_catalog_capture() {
use omnigraph::failpoints::names;
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap().to_string();
let owner = Arc::new(init_and_load(&dir).await);
let desired = TEST_SCHEMA.replace(
" age: I32?\n}",
" age: I32?\n nickname: String?\n}",
);
let open_rv =
helpers::failpoint::Rendezvous::park_first(names::OPEN_BEFORE_SCHEMA_CONTRACT_READ);
let open_uri = uri.clone();
let open_task = tokio::spawn(async move { Omnigraph::open_read_only(&open_uri).await });
open_rv.wait_until_reached().await;
let apply_rv =
helpers::failpoint::Rendezvous::park_first(names::SCHEMA_APPLY_AFTER_STAGING_WRITE);
let apply_owner = Arc::clone(&owner);
let apply_task = tokio::spawn(async move { apply_owner.apply_schema(&desired).await });
assert!(
tokio::time::timeout(
std::time::Duration::from_millis(200),
apply_rv.wait_until_reached(),
)
.await
.is_err(),
"schema apply must remain behind the ReadOnly catalog-capture gate",
);
open_rv.release();
let opened = open_task.await.unwrap().unwrap();
assert!(
!opened.catalog().node_types["Person"]
.properties
.contains_key("nickname"),
"the serialized open must publish the complete pre-apply catalog"
);
apply_rv.wait_until_reached().await;
apply_rv.release();
apply_task.await.unwrap().unwrap();
let current = Omnigraph::open_read_only(&uri).await.unwrap();
assert!(
current.catalog().node_types["Person"]
.properties
.contains_key("nickname"),
"the next open must publish the complete post-apply catalog"
);
}
/// Refresh must reacquire the schema gate after sidecar healing and retain it
/// through the ArcSwap publication. Otherwise a concurrent three-file schema
/// promotion can be interleaved with its contract read.
#[cfg(feature = "failpoints")]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial_test::serial]
async fn refresh_holds_schema_gate_through_catalog_publication() {
use omnigraph::failpoints::names;
let dir = tempfile::tempdir().unwrap();
let owner = Arc::new(init_and_load(&dir).await);
let stale = Arc::new(Omnigraph::open(dir.path().to_str().unwrap()).await.unwrap());
let desired = TEST_SCHEMA.replace(
" age: I32?\n}",
" age: I32?\n nickname: String?\n}",
);
let reload_rv =
helpers::failpoint::Rendezvous::park_first(names::SCHEMA_RELOAD_BEFORE_CONTRACT_READ);
let refresh_handle = Arc::clone(&stale);
let refresh_task = tokio::spawn(async move { refresh_handle.refresh().await });
reload_rv.wait_until_reached().await;
let apply_rv =
helpers::failpoint::Rendezvous::park_first(names::SCHEMA_APPLY_AFTER_STAGING_WRITE);
let apply_owner = Arc::clone(&owner);
let apply_task = tokio::spawn(async move { apply_owner.apply_schema(&desired).await });
assert!(
tokio::time::timeout(
std::time::Duration::from_millis(200),
apply_rv.wait_until_reached(),
)
.await
.is_err(),
"schema apply must remain behind refresh's catalog-publication gate",
);
reload_rv.release();
refresh_task.await.unwrap().unwrap();
apply_rv.wait_until_reached().await;
apply_rv.release();
apply_task.await.unwrap().unwrap();
stale.refresh().await.unwrap();
assert!(
stale.catalog().node_types["Person"]
.properties
.contains_key("nickname"),
"a post-apply refresh must publish the complete new catalog"
);
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn plan_schema_rejects_when_schema_contract_has_drifted() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
@ -49,6 +368,7 @@ async fn plan_schema_rejects_when_schema_contract_has_drifted() {
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_noop_returns_not_applied() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
@ -61,6 +381,7 @@ async fn apply_schema_noop_returns_not_applied() {
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_rejects_when_non_main_branch_exists() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
@ -79,6 +400,7 @@ async fn apply_schema_rejects_when_non_main_branch_exists() {
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_unsupported_plan_does_not_advance_manifest() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
@ -117,6 +439,7 @@ async fn apply_schema_unsupported_plan_does_not_advance_manifest() {
// contract so a regression in the planner can't silently change behavior.
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_drops_a_nullable_property_softly_preserves_prior_version() {
let dir = tempfile::tempdir().unwrap();
let mut db = init_and_load(&dir).await;
@ -221,6 +544,7 @@ async fn apply_schema_drops_a_nullable_property_softly_preserves_prior_version()
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_drops_node_and_referencing_edge_softly() {
let dir = tempfile::tempdir().unwrap();
let mut db = init_and_load(&dir).await;
@ -334,6 +658,7 @@ edge Knows: Person -> Person {
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_drops_an_edge_type_softly() {
let dir = tempfile::tempdir().unwrap();
let mut db = init_and_load(&dir).await;
@ -391,6 +716,7 @@ async fn apply_schema_drops_an_edge_type_softly() {
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_rejects_adding_a_required_property_without_backfill() {
let dir = tempfile::tempdir().unwrap();
let mut db = init_and_load(&dir).await;
@ -419,6 +745,7 @@ async fn apply_schema_rejects_adding_a_required_property_without_backfill() {
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn plan_schema_for_property_type_narrowing_is_not_supported() {
// Symmetric companion to `apply_schema_unsupported_plan_does_not_advance_manifest`,
// which exercises widening (I32 -> I64). Narrowing (I64 -> I32) is also
@ -446,6 +773,7 @@ async fn plan_schema_for_property_type_narrowing_is_not_supported() {
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_renames_node_type_via_rename_from_and_preserves_rows() {
// Covers the stable-type-id contract: renaming a type preserves the
// underlying Lance dataset (by stable id), so existing rows survive the
@ -535,6 +863,7 @@ edge WorksAt: Human -> Company
// persists. Full orphan-dataset deletion is a separate follow-up.
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_with_allow_data_loss_promotes_drops_to_hard() {
let dir = tempfile::tempdir().unwrap();
let mut db = init_and_load(&dir).await;
@ -601,6 +930,7 @@ async fn apply_schema_with_allow_data_loss_promotes_drops_to_hard() {
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_hard_drops_property_makes_prior_version_unreachable() {
let dir = tempfile::tempdir().unwrap();
let mut db = init_and_load(&dir).await;
@ -653,6 +983,7 @@ async fn apply_schema_hard_drops_property_makes_prior_version_unreachable() {
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_hard_drops_node_and_edge_with_flag_succeeds() {
let dir = tempfile::tempdir().unwrap();
let mut db = init_and_load(&dir).await;
@ -737,19 +1068,14 @@ edge Knows: Person -> Person {
// (no manifest entry), which is the user-facing guarantee.
}
// Regression (bug 3 / dev-graph iss-848): a `Vector @index` on a 0-row table
// must not abort an otherwise-valid schema apply. A vector (IVF) index trains
// k-means centroids over the column's vectors, so Lance cannot build it on 0
// vectors — it errors with "Creating empty vector indices with train=False is
// not yet implemented". When a *later* migration touches that table (here, an
// unrelated scalar `@index` on `body`), schema apply reconciles the table's
// whole index set, which previously tried to materialize the dormant vector
// index and aborted the entire migration (all-or-nothing). The build is now
// deferred (pending) when the column is untrainable, instead of failing the
// migration. The dormant index is materialized by a later `ensure_indices` /
// `optimize` once the table has rows. Full decoupling — intent recorded at
// apply, an async reconciler converges physical coverage — is iss-848.
// Regression (bug 3 / dev-graph iss-848): schema apply records index intent but
// performs no physical index work. That decoupling is load-bearing for a
// `Vector @index` on a 0-row table: Lance cannot train IVF centroids on no
// vectors, yet the logical migration must still succeed. A later
// `ensure_indices` / `optimize` materializes every buildable declaration once
// data exists and reports an untrainable vector column as pending meanwhile.
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn apply_schema_defers_vector_index_on_empty_table() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
@ -766,9 +1092,8 @@ async fn apply_schema_defers_vector_index_on_empty_table() {
}\n";
let mut db = Omnigraph::init(uri, v1).await.unwrap();
// Add an *unrelated* scalar @index on `body`. This routes Doc through
// schema apply's index reconcile, which must NOT abort on the untrainable
// empty vector index.
// Add an unrelated scalar @index on `body`. Schema apply must record both
// declarations without trying to build either one or train the empty vector.
let v2 = "node Doc {\n \
slug: String @key\n \
body: String? @index\n \
@ -779,11 +1104,8 @@ async fn apply_schema_defers_vector_index_on_empty_table() {
);
assert!(result.applied, "the scalar @index change must apply");
// The deferred vector index is not dropped — once the table has a
// trainable vector, `ensure_indices` materializes it without error. (If
// the guard wrongly skipped a non-empty column, this would still be
// unindexed; if it wrongly tried to build on empty, the apply above would
// have failed.)
// The deferred declarations are not dropped: after data arrives, the
// explicit reconciler materializes every buildable index without error.
load_jsonl(
&mut db,
r#"{"type":"Doc","data":{"slug":"d1","body":"hello","embedding":[0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8]}}"#,
@ -803,6 +1125,7 @@ async fn apply_schema_defers_vector_index_on_empty_table() {
// optimize. Pre-iss-848 the indexed_tables block built the index inline and
// bumped the table version.
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn index_only_constraint_apply_touches_no_table_data() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
@ -823,6 +1146,7 @@ async fn index_only_constraint_apply_touches_no_table_data() {
.entry("node:Doc")
.unwrap()
.table_version;
let before_commits = db.list_commits(None).await.unwrap();
// Add an @index on the existing `n` column.
let v2 = "node Doc {\n slug: String @key\n n: I64 @index\n}\n";
@ -840,6 +1164,12 @@ async fn index_only_constraint_apply_touches_no_table_data() {
before, after,
"adding an @index must not bump the table version (no inline index build)"
);
let after_commits = db.list_commits(None).await.unwrap();
assert_eq!(
after_commits.len(),
before_commits.len() + 1,
"metadata-only schema apply must still advance graph_head so it arbitrates concurrent prepared writes"
);
}
// Enum widening (iss-enum-widening-migration): adding variants to an enum is
@ -847,6 +1177,7 @@ async fn index_only_constraint_apply_touches_no_table_data() {
// touched, and the widened set is enforced immediately on writes. Narrowing
// stays OG-MF-106-refused.
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn enum_widening_apply_is_metadata_only_and_accepts_new_variant() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
@ -867,6 +1198,7 @@ async fn enum_widening_apply_is_metadata_only_and_accepts_new_variant() {
.entry("node:Ticket")
.unwrap()
.table_version;
let before_commits = db.list_commits(None).await.unwrap();
let v2 =
"node Ticket {\n slug: String @key\n status: enum(todo, doing, done, blocked)\n}\n";
@ -885,6 +1217,12 @@ async fn enum_widening_apply_is_metadata_only_and_accepts_new_variant() {
before, after,
"enum widening must not bump the table version (metadata-only)"
);
let after_commits = db.list_commits(None).await.unwrap();
assert_eq!(
after_commits.len(),
before_commits.len() + 1,
"metadata-only enum widening must still advance graph_head"
);
// The NEW variant is accepted on the write path...
load_jsonl(
@ -914,6 +1252,7 @@ async fn enum_widening_apply_is_metadata_only_and_accepts_new_variant() {
}
#[tokio::test]
#[cfg_attr(feature = "failpoints", serial_test::parallel)]
async fn enum_narrowing_apply_is_refused() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();

View file

@ -86,6 +86,7 @@ async fn init_search_db(dir: &tempfile::TempDir) -> Omnigraph {
load_jsonl(&mut db, SEARCH_DATA, LoadMode::Overwrite)
.await
.unwrap();
db.ensure_indices().await.unwrap();
db
}
@ -95,6 +96,7 @@ async fn init_mock_embedding_search_db(dir: &tempfile::TempDir) -> Omnigraph {
load_jsonl(&mut db, &mock_embedding_seed_data(), LoadMode::Overwrite)
.await
.unwrap();
db.ensure_indices().await.unwrap();
db
}
@ -104,6 +106,7 @@ async fn init_model_recorded_search_db(dir: &tempfile::TempDir) -> Omnigraph {
load_jsonl(&mut db, &mock_embedding_seed_data(), LoadMode::Overwrite)
.await
.unwrap();
db.ensure_indices().await.unwrap();
db
}
@ -202,6 +205,40 @@ async fn doc_user_index_count(db: &Omnigraph) -> usize {
.count()
}
/// RFC-022 data writes publish only their exact table effects. Declared FTS
/// and vector indexes may therefore still be pending immediately after load;
/// both retrieval modes (and their RRF composition) must remain logically
/// correct through Lance's flat-search paths.
#[tokio::test]
#[serial]
async fn deferred_indexes_do_not_block_hybrid_reads() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
let mut db = Omnigraph::init(uri, MOCK_SEARCH_SCHEMA).await.unwrap();
load_jsonl(
&mut db,
&mock_embedding_seed_data(),
LoadMode::Overwrite,
)
.await
.unwrap();
assert_eq!(
doc_user_index_count(&db).await,
0,
"load must leave declared physical indexes to the reconciler"
);
let result = query_main(
&mut db,
MOCK_SEARCH_QUERIES,
"hybrid_search_vector",
&vector_and_string_params("$vq", &mock_embedding("alpha", 4), "$tq", "alpha"),
)
.await
.expect("pending FTS/vector indexes must degrade to flat search");
assert_eq!(result_slugs(&result)[0], "alpha-doc");
}
struct EnvGuard {
saved: Vec<(&'static str, Option<String>)>,
}
@ -853,7 +890,7 @@ async fn rrf_fuses_two_vector_queries() {
#[tokio::test]
#[serial]
async fn mutation_commit_refreshes_search_indices_without_manual_ensure() {
async fn mutation_with_deferred_index_coverage_remains_searchable() {
let dir = tempfile::tempdir().unwrap();
let mut db = init_search_db(&dir).await;
assert_eq!(doc_user_index_count(&db).await, 4);
@ -879,7 +916,7 @@ async fn mutation_commit_refreshes_search_indices_without_manual_ensure() {
assert_eq!(
doc_user_index_count(&db).await,
4,
"mutation commit should refresh required indices without duplicating them"
"mutation must leave physical index materialization to the reconciler"
);
let result = query_main(
@ -892,7 +929,7 @@ async fn mutation_commit_refreshes_search_indices_without_manual_ensure() {
.unwrap();
assert!(
result_slugs(&result).contains(&"quasar-notes".to_string()),
"newly inserted row should be searchable without an explicit ensure_indices step"
"a row outside current index coverage must remain searchable via fallback scan"
);
}
@ -919,7 +956,7 @@ async fn rrf_fuses_vector_and_text() {
#[tokio::test]
#[serial]
async fn load_commit_creates_vector_index_for_vector_annotations() {
async fn index_reconciler_creates_vector_index_for_vector_annotations() {
let schema = r#"
node Doc {
slug: String @key
@ -935,6 +972,12 @@ node Doc {
load_jsonl(&mut db, data, LoadMode::Overwrite)
.await
.unwrap();
assert_eq!(
doc_user_index_count(&db).await,
0,
"load publishes exact data effects and leaves physical indexes pending"
);
db.ensure_indices().await.unwrap();
let ds = snapshot_main(&db)
.await

View file

@ -450,6 +450,56 @@ async fn stage_merge_insert_commit_rebases_over_disjoint_committed_delete() {
assert_eq!(collect_age_for_id(&batches, "p10"), None);
}
#[tokio::test]
async fn exact_commit_exposes_lance_preflight_rebase_version() {
let dir = tempfile::tempdir().unwrap();
let uri = format!("{}/people.lance", dir.path().to_str().unwrap());
let store = TableStore::new(dir.path().to_str().unwrap(), test_session());
let ds = TableStore::write_dataset(&uri, person_batch(&[("alice", Some(30))]))
.await
.unwrap();
let staged = store
.stage_append(&ds, person_batch(&[("bob", Some(25))]), &[])
.await
.unwrap();
let planned = staged.transaction_identity();
assert_eq!(planned.read_version, 1);
// A foreign append lands after staging. Even with max_retries(0), Lance
// performs one preflight conflict-resolution pass and can rebase this
// append before its sole manifest-write attempt. Lance preserves the
// transaction fields, so the exact path must expose the later achieved
// version as the second half of the identity check.
let mut foreign = ds.clone();
lance_append_inline_local(&mut foreign, person_batch(&[("carol", Some(40))])).await;
let (committed, observed) = store
.commit_staged_exact(Arc::new(ds), staged)
.await
.unwrap();
assert_eq!(committed.version().version, 3);
assert_eq!(observed, planned);
assert_ne!(
committed.version().version,
planned.read_version + 1,
"Lance preserves transaction identity during this preflight rebase; \
the achieved version is the second half of exact-effect identity"
);
// Without a concurrent advance, the same one-attempt path lands the exact
// staged transaction identity.
let next = store
.stage_append(&committed, person_batch(&[("dave", Some(50))]), &[])
.await
.unwrap();
let next_planned = next.transaction_identity();
let (_committed, next_observed) = store
.commit_staged_exact(Arc::new(committed), next)
.await
.unwrap();
assert_eq!(next_observed, next_planned);
}
/// **Documented limitation** (see `scan_with_staged` doc): when a filter
/// is supplied, Lance's stats-based pruning drops the staged fragment from
/// the filtered scan because uncommitted fragments produced by
@ -1075,10 +1125,9 @@ async fn lance_restore_appends_one_commit_with_checked_out_content() {
);
}
/// Empirical pin of the `Dataset::restore` concurrency hazard that
/// motivates the recovery sweep's open-time-only invocation strategy
/// and any future continuous-recovery reconciler's queue-acquisition
/// requirement.
/// Empirical pin of the `Dataset::restore` concurrency hazard that requires
/// Full recovery to join the root-scoped writer gates and leaves a real
/// cross-process fencing boundary.
///
/// `Dataset::restore`'s `check_restore_txn` (lance-6.0.1
/// `src/io/commit/conflict_resolver.rs:986`) returns `Ok(())` against
@ -1091,18 +1140,17 @@ async fn lance_restore_appends_one_commit_with_checked_out_content() {
/// rewind commit AFTER the legitimate concurrent Append, silently
/// orphaning that Append's data from the active timeline.
///
/// The recovery sweep sidesteps this by running only at `Omnigraph::open`
/// (before any other writers can race). A future continuous-recovery
/// reconciler must acquire per-(table_key, branch) queues for sidecar
/// tables before invoking restore — otherwise this hazard becomes
/// reachable during in-flight tenant traffic.
/// Full recovery may invoke Restore on a read-write open, but first joins the
/// same root-scoped schema → branch → sorted-table gates as live writers and
/// rechecks that the sidecar still exists after waiting. The in-process healer
/// is roll-forward-only and never restores beneath live traffic. Together those
/// rules keep this substrate asymmetry unreachable against another handle in
/// the same process.
///
/// MR-686 introduces those per-(table_key, branch) writer queues as the
/// application-layer mechanism that closes this hazard once continuous
/// in-process recovery (MR-870) lands. Until MR-686's queue is wired into
/// the recovery path, the open-time-only invocation strategy is the
/// only thing keeping this hazard out of production. See
/// `docs/invariants.md` §VI.30, §VI.32, §VI.33.
/// The gates remain process-local. A recovery pass cannot fence a live writer
/// in another process, so general multi-process write/recovery topologies remain
/// outside the supported boundary until a distributed fence exists. See
/// `docs/dev/invariants.md` and `docs/dev/writes.md`.
///
/// This test is the load-bearing constraint any future reconciler must
/// honor.
@ -1159,10 +1207,10 @@ async fn lance_restore_loses_to_concurrent_append_via_orphaning() {
ids,
vec!["alice".to_string()],
"Concurrent Append's row 'bob' was silently orphaned by the \
Restore. Active-timeline contents == v1's contents. The recovery \
sweep sidesteps this hazard via open-time-only invocation; any \
future continuous-recovery reconciler must guard against it via \
per-(table, branch) queue acquisition. Got: {:?}",
Restore. Active-timeline contents == v1's contents. Full recovery \
must join the root-scoped writer gates before Restore; those gates \
remain process-local, so a distributed fence is required before \
multi-process recovery can be supported. Got: {:?}",
ids,
);

View file

@ -58,7 +58,8 @@ async fn key_column_index_coverage_detects_btree_presence() {
let db = init_and_load(&dir).await;
let snap = snapshot_main(&db).await.unwrap();
// Edge `src` gets a BTREE from ensure_indices on load → Indexed.
// The shared fixture explicitly reconciles indexes after loading, so the
// edge `src` BTREE is present and fully covered here.
let edge_ds = snap.open("edge:Knows").await.unwrap();
let src_cov = TableStore::key_column_index_coverage(&edge_ds, "src")
.await
@ -86,7 +87,8 @@ async fn coverage_degrades_for_appended_unindexed_fragment() {
let dir = tempfile::tempdir().unwrap();
let mut db = init_and_load(&dir).await;
// Fresh load: the Knows BTREE covers every fragment → Indexed.
// The fixture's explicit post-load `ensure_indices` covers every current
// Knows fragment → Indexed.
let snap = snapshot_main(&db).await.unwrap();
let edge_ds = snap.open("edge:Knows").await.unwrap();
assert_eq!(

View file

@ -5,8 +5,9 @@
//! boundary. Guards invariant 15 (cost bounded by work, not history) on writes.
//!
//! **Backend split (see docs/dev/testing.md / RFC-013).** This file runs on
//! **local FS** and gates the **internal-table** term (`__manifest`/`_graph_commits`
//! fragment scans, ~+18/depth — O(fragments) on any backend, step 2's target).
//! **local FS** and gates the **internal-table** term (`__manifest` fragment
//! scans, including inline lineage/actor rows — O(fragments) on any backend,
//! step 2's target). The former standalone lineage tables are retired.
//!
//! The **data-table opener** term (step 3a's win) is a per-object-store-RPC
//! phenomenon and is NOT gated here: local-FS latest-resolution is cheap whether
@ -270,14 +271,16 @@ async fn keyed_insert_routes_through_merge_insert_only() {
// ── (D) Step-3b capture-once fitness asserts (RED today → GREEN after WriteTxn) ──
/// A write must validate the schema contract EXACTLY ONCE (3 `read_text` + 2 `exists`).
/// Today the write path re-validates at every resolve point (entry, per-table
/// `resolved_branch_target`, commit-time `fresh_snapshot_for_branch`), so the delta is
/// a multiple of that. Step 3b's `WriteTxn` validates once and threads it. The shape is
/// A write performs one full schema validation while capturing the catalog-bound
/// `WriteTxn`, one trailing state-marker read that fences torn head/schema capture,
/// and one full validation under the pre-effect gates (7 `read_text` + 4 `exists`
/// total). Per-table resolves must not add more validation. The gate read is
/// correctness work: it arbitrates schema identity after preparation and before
/// the recovery sidecar or any Lance HEAD movement. The shape is
/// the write twin of `warm_read_cost.rs::warm_query_validates_schema_contract_once`,
/// built with ZERO production change via the counting storage adapter.
#[tokio::test]
async fn write_validates_schema_contract_once() {
async fn write_schema_io_is_bounded_to_capture_fence_and_effect_gate() {
use omnigraph::instrumentation::CountingStorageAdapter;
use omnigraph::storage::storage_for_uri;
@ -291,6 +294,8 @@ async fn write_validates_schema_contract_once() {
let before_read_text = counts.read_text();
let before_exists = counts.exists();
let before_write_text = counts.write_text();
let before_delete = counts.delete();
db.mutate(
"main",
MUTATION_QUERIES,
@ -302,14 +307,24 @@ async fn write_validates_schema_contract_once() {
let read_text_delta = counts.read_text() - before_read_text;
let exists_delta = counts.exists() - before_exists;
let write_text_delta = counts.write_text() - before_write_text;
let delete_delta = counts.delete() - before_delete;
eprintln!("schema-contract reads on one write: read_text={read_text_delta} exists={exists_delta}");
assert_eq!(
read_text_delta, 3,
"a write must validate the schema contract once (3 reads), not N times",
read_text_delta, 7,
"a write must do capture validation + trailing identity fence + pre-effect validation (7 reads), not per table",
);
assert_eq!(
exists_delta, 2,
"a write must probe contract-file existence once (2 probes), not N times",
exists_delta, 4,
"a write must probe contract-file existence at capture + pre-effect revalidation (4 probes)",
);
assert_eq!(
write_text_delta, 2,
"an enrolled write must write its recovery sidecar exactly twice (arm + exact confirmation)",
);
assert_eq!(
delete_delta, 1,
"a successful enrolled write must delete its confirmed sidecar once",
);
}

View file

@ -1,13 +1,17 @@
//! Tests for the direct-publish write path: mutations and loads write
//! directly to target tables and commit once via the publisher's
//! `expected_table_versions` CAS. (History: this replaced the removed Run
//! Tests for the direct-publish write path. Mutations and loads capture one
//! branch-wide authority token, prepare exact per-table transactions, then
//! acquire the root-shared schema → branch → sorted-table gates, revalidate,
//! arm schema-v3 recovery, commit the table effects, and publish once under the
//! same exact-head/table precondition. (History: this replaced the removed Run
//! state machine / `__run__` staging branches / RunRecord — MR-771.)
//!
//! What this file covers:
//! - No `__run__*` branches are created by load or mutate.
//! - Cancellation of a mutation future leaves no graph-level state.
//! - Concurrent non-strict inserts/merges rebase under the per-table queue;
//! strict updates/deletes surface `ExpectedVersionMismatch` on stale state.
//! - A pre-effect branch-authority change makes an insert-only mutation or
//! Append/Merge load discard and fully reprepare with a bounded retry; strict
//! Update/Delete/Overwrite surfaces `ReadSetChanged`. Post-effect failures
//! require recovery.
//! - Failed mutations and loads leave the target unchanged.
//! - Multi-statement mutations are atomic (one commit per query).
//! - actor_id propagates through to the commit graph.
@ -242,11 +246,12 @@ async fn partial_failure_leaves_target_queryable_and_unblocks_next_mutation() {
assert_eq!(frank.num_rows(), 1, "Frank must be visible after publish");
}
/// Stale non-strict writers rebase to the live manifest pin under the
/// per-table queue instead of folding raw drift or returning a false 409.
/// Strict update/delete semantics are covered by the consistency/server tests.
/// Stale non-strict writers discard and reprepare their whole logical attempt
/// from the live branch authority instead of rebasing an already-validated
/// staged transaction or returning a false 409. Strict update/delete semantics
/// are covered by the consistency/server tests.
#[tokio::test]
async fn stale_non_strict_insert_rebases_to_live_manifest_pin() {
async fn stale_non_strict_insert_reprepares_from_live_branch_state() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_string_lossy().into_owned();
@ -276,9 +281,10 @@ async fn stale_non_strict_insert_rebases_to_live_manifest_pin() {
}
// Writer B's coordinator is still at the pre-A snapshot, but Insert is
// non-strict: commit_all re-reads the live manifest pin under the queue,
// verifies Lance HEAD equals that pin, and then lets Lance rebase the
// staged append.
// retryable: the RFC-022 adapter notices the authority change under the
// branch gate, discards B's prepared/staged attempt, and reruns the full
// operation from A's committed state. Lance never rebases a plan whose
// validation inputs are stale.
db_b.mutate(
"main",
MUTATION_QUERIES,
@ -598,8 +604,16 @@ async fn overlapping_delete_predicates_do_not_double_count_affected() {
);
// The data is correct regardless of the count: Bob + Diana remain.
assert_eq!(count_rows(&db, "node:Person").await, 2, "Bob and Diana remain");
assert_eq!(count_rows(&db, "edge:Knows").await, 1, "only Bob→Diana remains");
assert_eq!(
count_rows(&db, "node:Person").await,
2,
"Bob and Diana remain"
);
assert_eq!(
count_rows(&db, "edge:Knows").await,
1,
"only Bob→Diana remains"
);
assert_eq!(
count_rows(&db, "edge:WorksAt").await,
1,
@ -633,7 +647,9 @@ edge Knows: Person -> Person
{"type":"Person","data":{"name":"Zoe"}}
{"edge":"Knows","from":"Zoe","to":"Charlie"}"#;
let mut db = Omnigraph::init(uri, schema).await.unwrap();
load_jsonl(&mut db, data, LoadMode::Overwrite).await.unwrap();
load_jsonl(&mut db, data, LoadMode::Overwrite)
.await
.unwrap();
let q = r#"
query del_age_then_name($threshold: I32, $name: String) {
@ -668,7 +684,7 @@ query del_age_then_name($threshold: I32, $name: String) {
/// `insert Person 'X'; update Person where name='X' set age=...` — both
/// ops produce content on `node:Person` and coalesce into one
/// `stage_merge_insert` at end-of-query. The accumulator's last-write-wins
/// dedupe (in `MutationStaging::finalize`) ensures the update's value
/// dedupe (during `MutationStaging::stage_all`) ensures the update's value
/// wins. Single Lance commit per table per query.
#[tokio::test]
async fn mixed_insert_and_update_on_same_person_coalesces_to_one_merge() {
@ -692,7 +708,7 @@ async fn mixed_insert_and_update_on_same_person_coalesces_to_one_merge() {
assert_eq!(result.affected_nodes, 2, "1 insert + 1 update reported");
// The end-state row carries the update value (last-write-wins via
// dedupe in finalize), proving the staged merge_insert ran with the
// end-of-query dedupe), proving the staged merge_insert ran with the
// correct source dedupe. Read the underlying Person table directly
// and assert age=99 for the row we just inserted+updated.
let batches = read_table(&db, "node:Person").await;
@ -1063,7 +1079,7 @@ edge WorksAt: Person -> Company @card(0..1)
/// `scan_with_pending`, the second update sees the stale committed value
/// (the first update's row still appears in the Lance scan because the
/// pending side hasn't committed), the predicate matches it, and the
/// dedupe-last-wins step at finalize ends up applying the second update
/// end-of-query dedupe-last-wins step ends up applying the second update
/// to a row whose pending value should have shielded it.
///
/// Concretely: Alice starts at age=30 in TEST_DATA. Op-1 sets Alice to
@ -1347,7 +1363,7 @@ edge WorksAt: Person -> Company @card(0..1)
}
/// A Merge load whose input has TWO rows with the same edge id must be
/// deduped at cardinality-count time, not just at finalize. Without
/// deduped at cardinality-count time, not just during end-of-query staging. Without
/// dedup, two pending rows count twice → spurious `@card` violation.
/// With dedup (last-occurrence-wins, mirroring
/// `dedupe_merge_batches_by_id`), the pending side counts once.
@ -1382,7 +1398,7 @@ edge WorksAt: Person -> Company @card(0..1)
.unwrap();
// Merge load with the SAME edge id twice — the second row supersedes
// the first in the finalize-time dedupe. If pending-counting doesn't
// the first in the end-of-query dedupe. If pending-counting doesn't
// dedupe, Alice has 2 pending edges → @card(0..1) trips → load
// fails. With dedupe, Alice has 1 → load succeeds.
let dup_data = r#"{"edge": "WorksAt", "from": "Alice", "to": "Acme", "data": {"id": "w1"}}
@ -1490,21 +1506,13 @@ async fn scan_with_pending_rejects_key_column_missing_from_projection() {
);
}
/// `PendingTable.schema` is captured from the first `append_batch` call
/// and never updated. On a blob-bearing table, an `insert` produces a
/// full-schema batch (blob columns included) and an `update` that
/// doesn't assign every blob produces a subset-schema batch. Mixed in
/// one query, the second `append_batch` would silently push an
/// incompatible batch — the mismatch surfaced eventually at
/// `concat_batches`/MemTable construction inside finalize, but the
/// failure point was distant from the offending op.
///
/// `append_batch` validates the new batch's schema against the existing
/// accumulator's schema and returns a typed error directing the caller
/// to split the mutation. The error fires at the second op (the
/// update), not at end-of-query.
/// A blob-table insert followed by a non-blob update must remain one
/// full-schema merge stream. The update reads the just-inserted pending row,
/// copies its logical blob column through, and replaces the pending row under
/// the existing last-write-wins shadow semantics. This used to produce a
/// partial update batch and fail schema validation at the second op.
#[tokio::test]
async fn append_batch_rejects_mismatched_schema_in_blob_table_at_offending_op() {
async fn blob_table_insert_then_non_blob_update_preserves_full_schema() {
use omnigraph::loader::{LoadMode, load_jsonl};
const BLOB_SCHEMA: &str = r#"
@ -1527,10 +1535,9 @@ query insert_then_update_note(
let uri = dir.path().to_str().unwrap();
let mut db = Omnigraph::init(uri, BLOB_SCHEMA).await.unwrap();
// Seed with a Document so the update has something to match (the
// mid-query case is the chained-update scenario where the update's
// predicate matches the just-inserted row, exercising the in-memory
// pending union).
// Keep one committed blob row as well as the just-inserted pending row so
// the table has the real blob-v2 physical representation while this query
// exercises the pending union.
load_jsonl(
&mut db,
r#"{"type":"Document","data":{"title":"seed","content":"base64:AQID"}}"#,
@ -1539,7 +1546,7 @@ query insert_then_update_note(
.await
.unwrap();
let err = db
let result = db
.mutate(
"main",
BLOB_QUERIES,
@ -1551,36 +1558,35 @@ query insert_then_update_note(
]),
)
.await
.expect_err("blob-table mixed insert+update with non-fully-assigned blob must error early");
let OmniError::Manifest(manifest_err) = err else {
panic!("expected Manifest error, got {err:?}");
};
assert!(
manifest_err.message.contains("mismatched schemas")
&& manifest_err.message.contains("Split the mutation"),
"error must direct user to split: {}",
manifest_err.message,
.expect("blob-table insert + non-blob update must share a full-schema merge batch");
assert_eq!(
result.affected_nodes, 2,
"one insert plus one pending-row update"
);
// Confirm the manifest didn't advance — early error must be
// before any commit.
let blob = db
.read_blob("Document", "letter", "content")
.await
.expect("inserted blob must remain readable after the update");
assert_eq!(&blob.read().await.unwrap()[..], &[4, 5, 6]);
let qr = db
.query(
ReadTarget::branch("main"),
r#"query get_doc($title: String) {
match { $d: Document { title: $title } }
return { $d.title }
return { $d.title, $d.note }
}"#,
"get_doc",
&params(&[("$title", "letter")]),
)
.await
.unwrap();
assert_eq!(
qr.num_rows(),
0,
"letter must not be visible after early error"
);
assert_eq!(qr.num_rows(), 1);
let json = qr.to_sdk_json();
let row = json.as_array().unwrap().first().unwrap();
assert_eq!(row["d.title"], "letter");
assert_eq!(row["d.note"], "draft 1");
}
/// MR-920 regression: two sequential `update T set {f:v} where x=y`
@ -1794,7 +1800,9 @@ async fn camelcase_mutation_predicate_updates_and_deletes() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
let mut db = Omnigraph::init(uri, CC_SCHEMA).await.unwrap();
load_jsonl(&mut db, CC_DATA, LoadMode::Overwrite).await.unwrap();
load_jsonl(&mut db, CC_DATA, LoadMode::Overwrite)
.await
.unwrap();
let m = r#"
query set_status($repo: String, $st: String) { update Doc set { status: $st } where repoName = $repo }
@ -1802,7 +1810,12 @@ query del($repo: String) { delete Doc where repoName = $repo }
"#;
let upd = db
.mutate("main", m, "set_status", &params(&[("$repo", "acme"), ("$st", "closed")]))
.mutate(
"main",
m,
"set_status",
&params(&[("$repo", "acme"), ("$st", "closed")]),
)
.await
.expect("update with a camelCase predicate must execute");
assert_eq!(upd.affected_nodes, 1, "exactly the acme Doc should update");
@ -1811,9 +1824,16 @@ query del($repo: String) { delete Doc where repoName = $repo }
.mutate("main", m, "del", &params(&[("$repo", "globex")]))
.await
.expect("delete with a camelCase predicate must execute");
assert_eq!(del.affected_nodes, 1, "exactly the globex Doc should delete");
assert_eq!(
del.affected_nodes, 1,
"exactly the globex Doc should delete"
);
assert_eq!(count_rows(&db, "node:Doc").await, 1, "one Doc (acme) should remain");
assert_eq!(
count_rows(&db, "node:Doc").await,
1,
"one Doc (acme) should remain"
);
}
// #283 (pending side): a chained mutation whose 2nd op filters a camelCase
@ -1825,7 +1845,9 @@ async fn camelcase_chained_mutation_reads_pending_by_camelcase() {
let dir = tempfile::tempdir().unwrap();
let uri = dir.path().to_str().unwrap();
let mut db = Omnigraph::init(uri, CC_SCHEMA).await.unwrap();
load_jsonl(&mut db, CC_DATA, LoadMode::Overwrite).await.unwrap();
load_jsonl(&mut db, CC_DATA, LoadMode::Overwrite)
.await
.unwrap();
// op-1 stages a status change to the acme Doc; op-2 re-filters the same
// camelCase column, so it must match op-1's pending row.
@ -1838,8 +1860,13 @@ query chain($repo: String) {
let r = db
.mutate("main", m, "chain", &params(&[("$repo", "acme")]))
.await
.expect("chained camelCase mutation must read the pending row, not fail at the MemTable SELECT");
assert_eq!(r.affected_nodes, 2, "both ops should touch the acme Doc (read-your-writes)");
.expect(
"chained camelCase mutation must read the pending row, not fail at the MemTable SELECT",
);
assert_eq!(
r.affected_nodes, 2,
"both ops should touch the acme Doc (read-your-writes)"
);
}
/// A zero-row cascade delete must not advance an edge table's Lance HEAD past
@ -1992,7 +2019,9 @@ async fn filtered_read_after_merge_update_and_delete_keeps_row_ids_consistent()
)
})
.collect();
load_jsonl(&mut db, &updates, LoadMode::Merge).await.unwrap();
load_jsonl(&mut db, &updates, LoadMode::Merge)
.await
.unwrap();
// The delete adds a deletion vector, so the overlapping region no longer
// densely tiles its id range — the shape lance#7444 choked on.