mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-07-15 03:22: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
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue