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

* Implement unified graph write protocol

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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