Skip to content

fix(server): settle an embedded error's facts before it is logged - #1123

Merged
ragnorc merged 3 commits into
ModernRelay:mainfrom
ragnorc:fix/embedded-log-sent-status
Oct 22, 2026
Merged

ragnorc merged 3 commits into
ModernRelay:mainfrom
ragnorc:fix/embedded-log-sent-status

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What

The review of #1117 left one optional note: the embedded helpers engine_error_output and engine_error_output_after set their facts (one step's judgment, or the caller's write fact) after ApiError::from_omni returned, so its engine-failure warning read the status before them. A DataFusion failure judged as a step warned status=400 Bad Request while its body answered 500 outcome_unknown.

from_omni now delegates to from_omni_settled, which takes the error's final facts before the warning reads its status. The served path passes the request's facts, as before; each embedded helper passes its own. into_step_output keeps its behavior through the extracted into_step. The translation is its own function and from_omni_settled its one exit, so every result, including the internal error a conflict naming invalid graph type metadata becomes, takes its operation's facts and is logged; that replacement takes no completion evidence from its cause. No body, status or disposition changes against main. Two log changes: the embedded warning now matches its body, and those four internal replacements, silent before, now log the warning. The CLI installs no tracing subscriber today, so no CLI user sees the line, but the property now holds on both paths.

Why

Follow-up to the optional finding on #1117: a log line and its response must not disagree, whichever path builds them.

Tests

api_error_tests::an_uncertain_engine_failure_logs_the_status_its_response_sends gains three embedded cases, a step (engine_error_output), a failure after a write (engine_error_output_after(_, true)) and a read (engine_read_error_output), each asserting that the captured warning's status equals the body's http_status().

  • Before, with the helpers restored to their main form and the test kept: the test fails; the warning logs status=400 Bad Request for the step case whose body is 500.
  • After: cargo test -p omnigraph-server --lib --locked 214 passed, 2 ignored.
  • cargo clippy -p omnigraph-server --all-targets --locked -- -D warnings -W clippy::dbg_macro: clean. cargo fmt --all --check: clean.

The engine-failure warning in `from_omni` reads the error's status once
its request's facts are on it. The embedded helpers set their own facts
after it returned: `engine_error_output` judges one step, and
`engine_error_output_after` takes the caller's write fact. A query
execution failure judged as a step therefore warned 400 while its body
answered 500 `outcome_unknown`.

`from_omni_settled` takes the facts before the warning: the served path
passes the request's, and each embedded helper passes its own, so the
warning and the body agree on both paths.
@ragnorc
ragnorc requested a review from aaltshuler as a code owner October 21, 2026 17:48
@cursor cursor Bot mentioned this pull request Oct 21, 2026
6 of 7 tasks

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Why

The warning engine failure without definite completion evidence and the error body must name one status. The review of #1117 left the embedded helpers on the wrong side of that read. engine_error_output and engine_error_output_after set the write fact after from_omni had already logged.

What

from_omni now calls from_omni_settled. The caller passes the final facts. The served path passes the request fact, as before. engine_error_output passes the step fact (into_step). engine_error_output_after passes the caller's after_write flag. The warning then reads status.

How

For a normal engine error, both readers use the facts the caller passed. into_step_output uses the same into_step assignment as the step helper, so a merge's source-deletion detail keeps its old body.

Impact

A DataFusion failure judged as a step logged status=400 Bad Request and answered 500 outcome_unknown. On this head the warning says 500, and the body stays 500. A read stays 400 on both. The CLI installs no tracing subscriber, so a CLI user does not see the line. The HTTP status of a normal engine error does not change.

Recommendation: do not merge this head. Two blocking items are open.

Blocking

1. Four returns skip settle and change the embedded body

from_omni_settled returns ApiError::internal(...) before settle when type metadata cannot be projected:

  • published dataset version conflict, line 1470
  • merge conflict, line 1509
  • key conflict, line 1518
  • branch start refusal, line 1592

ApiError::internal sets completion_uncertain. On the parent, engine_error_output still ran into_step_output after that return, and engine_error_output_after(_, true) still set owned_write. On this head those assignments do not run.

Probe input: OmniError::key_conflict("Person", "Alice").

Commit Helper code effects action HTTP
parent 10f5517c engine_error_output and engine_error_output_after(_, true) outcome_unknown unknown reconcile 500
head 674433c same two helpers internal none refresh 500

A valid key node:Person stays conflict, effects none, action refresh, HTTP 409 on both commits. The served path is unchanged. This return already skipped with_request_facts, and into_response still applies the request fact.

An embedded write uses this disposition (EngineWriteError in command_outcome.rs). Effects unknown exits 2. Effects none exits 1. The JSON code changes from outcome_unknown to internal.

The PR text says that no body, status, or disposition changes. These exits change the embedded code and disposition. The HTTP number stays 500.

Use one exit for every return. Call settle, then emit the existing warning when completion_uncertain is set. Do not apply the original error's completion evidence to this replacement internal error. These four errors are silent today. The one exit logs them, at the settled status. Extend an_uncertain_engine_failure_logs_the_status_its_response_sends with one invalid type key through engine_error_output and through engine_error_output_after(_, true). Assert code, disposition, and the warning status.

Inline on crates/omnigraph-server/src/lib.rs:1445 (from_omni_settled): same finding. The four returns are lines 1470, 1509, 1518, and 1592.

2. Rebase onto current main and keep scoped_unknown before settle

This head conflicts with main at 888223b7. A merge of main into this commit conflicts only in crates/omnigraph-server/src/lib.rs. main (#1096) assigns response.scoped_unknown and then calls with_request_facts. This head calls settle in that same place. The warning's scoped field merged from main.

Keep both, in this order:

response.scoped_unknown = response.completion_uncertain && storage_outcome;
let response = settle(response);

Keep scoped = response.scoped_unknown on the warning. docs/user/operations/storage-failures.md on main says this line records whether the uncertainty is scoped to the storage keys. A resolution that keeps only settle leaves scoped_unknown false. contains_process then treats every uncertain error as process containment, and the log field is wrong.

#1124 adds a publication field on this same warning. Capture the id before into_completion_evidence, and log it after settle.

Inline on crates/omnigraph-server/src/lib.rs:1756 (let response = settle(response)): same rebase requirement.

Optional

  1. The new cases call the helpers with no request write in progress. That is the mismatch reproduced for step (warning 400, body 500). The other direction is a read helper after submit has set the request write fact: the old warning can say 500 while the body stays 400. settle overwrites owned_write before the read, so this head covers that direction. One case inside scope_request, after a submitted write, would lock it.

  2. fix(server): send WWW-Authenticate on every 401 #1122 also edits ApiError::into_response. This PR does not. fix(server): send WWW-Authenticate on every 401 #1122 is behind fix(server): log the status an error response sends #1117: its patch still builds the body with into_output() and then reads the status. On current main, into_response calls with_request_facts and then status(). Keep that order when fix(server): send WWW-Authenticate on every 401 #1122 rebases. The WWW-Authenticate extension is independent of settle.

Tradeoff and liability

The observable contract is one status. The warning and the body are two readers. The write fact selects 400 or 500 for an uncertain DataFusion error. The warning stays at the mapping site so it shares the write task's span with the containment log. A log inside into_judged_output would run later, on the waiter. The settle callback buys that span. It is the right single door for the caller's facts.

This removes the disagreement for every error that reaches settle. The merge source-deletion path calls from_omni and then into_step_output inside the owned write. The warning runs only when completion is uncertain, and the step fact then sets owned_write, which that write already set. MCP applies after_owned_write to an ApiError the handler already mapped inside that same scope. Those two agree today.

This adds a second exit. The four early returns skip the door, and they change the embedded body. The next early return does the same. One exit removes that liability. scoped from #1096 and publication from #1124 belong on that exit. A second logger would split the contract again.

The callback is a small primitive. Five more embedded fact patterns can pass a closure into the same exit. They do not need a new type.

No hot-path cost. settle runs once per mapped error.

Validation

GQT cannot express this contract. The contract is a server tracing field against the error body. It is not rows, counts, column types, or a query error. The owner is api_error_tests in omnigraph-server. The PR extends the right test.

Before: parent 10f5517c5d41c433005603b0c5a29358ec9560a8. The three embedded assertions from this PR were added to an_uncertain_engine_failure_logs_the_status_its_response_sends. The helpers stayed as they are on the parent.

cargo test -p omnigraph-server --lib --locked api_error_tests::an_uncertain_engine_failure_logs_the_status_its_response_sends -- --exact --test-threads=1 --nocapture

Result: fail. Served read logged status=400 Bad Request. Served write, before the owned write and inside it, logged status=500 Internal Server Error, and the containment line logged 500. The embedded step case then failed. The body check passed at HTTP 500. The warning was status=400 Bad Request. The loop did not reach after a write or read.

The invalid-key probe on that same parent is the table above.

After: exact head 674433c7d2b35b0472e6ce2a9cd2eaadabcac78c. Same command, the PR's own assertions.

Result: pass. 1 passed.

The invalid-key probe on that head is the table above.

The temporary probe and the parent-only assertions were removed. Nothing was pushed.

CI on this exact commit passed, including Test Workspace, Lint (clippy), Format (rustfmt), and GQ Logic Tests. That run uses this head's base. The base does not contain #1096. The merge with main was not compiled.

Limitations: the full workspace suite was not run locally. Performance was not measured. This path does not depend on Lance. The log contract in docs/user/operations/storage-failures.md was read on the parent and on current main.

# Conflicts:
#	crates/omnigraph-server/src/lib.rs
Four translations returned an internal error before `settle` when a
conflict named invalid graph type metadata, so the embedded helpers'
facts never reached it: a key conflict on `Person` judged as a step
answered `internal` with no effects, where it had answered
`outcome_unknown`. Those returns also skipped the warning.

The translation is now its own function, and `from_omni_settled` is its
one exit: every result, the internal replacement included, takes its
operation's facts and is logged at the status its body sends. The
replacement takes no completion evidence from its cause, and main's
`scoped_unknown` is set before the facts.
@ragnorc

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at fcd792d, after merging main (5fdf910).

Blocking 1: four returns skipped settle. The translation is now its own function (translated), and from_omni_settled is its one exit: settle, then the warning, for every result. The internal error a conflict naming invalid graph type metadata becomes now takes the embedded helpers' facts again (outcome_unknown, unknown, reconcile, as on the parent), takes no completion evidence from its cause, and is logged at its settled status. The test runs OmniError::key_conflict("Person", "Alice") through engine_error_output and engine_error_output_after(_, true) and asserts the code, the disposition and the warning's 500.

Blocking 2: main. Merged; response.scoped_unknown = response.completion_uncertain && storage_outcome stays before settle, and the warning keeps scoped. #1124's publication field can join the same warning.

Optional 1. Added: engine_read_error_output inside scope_request after a submitted write keeps 400 in both the body and the warning.

Optional 2. Noted for #1122's rebase; this PR does not touch into_response.

cargo test -p omnigraph-server --lib --locked: 214 passed, 2 ignored. Clippy and fmt clean. The PR body now names the two log changes.

@ragnorc
ragnorc added this pull request to the merge queue Oct 22, 2026
Merged via the queue into ModernRelay:main with commit 242025c Oct 22, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant