mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-07-12 03:12:11 +02:00
Implement RFC-022 unified graph write protocol (#343)
* Implement unified graph write protocol * Preserve recovery error wire compatibility
This commit is contained in:
parent
0c8d769501
commit
f758ff0d17
80 changed files with 13393 additions and 2050 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" = [])),
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ¤t_source_ir)
|
||||
validate_current_source_matches(&state, ¤t_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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?)),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
¶ms(&[("$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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
¶ms(&[("$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", ¶ms(&[("$repo", "acme"), ("$st", "closed")]))
|
||||
.mutate(
|
||||
"main",
|
||||
m,
|
||||
"set_status",
|
||||
¶ms(&[("$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", ¶ms(&[("$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", ¶ms(&[("$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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue