Skip to content

fix(catalog): a publication that never sent its manifest is unpublished - #1092

Merged
aaltshuler merged 12 commits into
ModernRelay:mainfrom
ragnorc:fix/publication-outcome-from-commit
Oct 20, 2026
Merged

aaltshuler merged 12 commits into
ModernRelay:mainfrom
ragnorc:fix/publication-outcome-from-commit

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

What & why

A publisher failure no longer stops the server when no manifest request can land. Every OmniGraph dataset commits through one configured CommitHandler. That handler now re-issues an ambiguous manifest put-if-absent, unchanged, until the store answers it. It records, per commit, whether a request was sent and how it ended. The catalog publisher uses that record and the attempt's phase to set completion evidence on every failure, instead of inferring it from where the error came from.

This closes the publisher half of #1067. It fixes the 2026-10-19 containment, which #1070 did not cover.

  • Before: 0.14.0-edge+0a62bb7 served one mutate on main. Tigris answered the __manifest/_transactions/5268-….txn PUT 503 SlowDown. Lance writes that file before the manifest and returns its error with a bare ? (lance-11.0.0/src/io/commit.rs:1397), so no manifest request was sent. The publisher read version 5269 back as absent, but its rule called absence definite only for a failure "before any store request" (publisher.rs:1455), so it answered manifest_publish_in_doubt. The server then closed admission for every graph, exited 2 with the lock held, and stayed down about 16 hours.
  • After: the same failure is Unpublished. The client gets a 500 and the server keeps serving.

The full root-cause chain for both incidents, with line references at main, is in the Motivation of the storage outcomes RFC, #1093.

Backing issue / RFC

Changes

  • omnigraph-core::manifest_request (new). ManifestRequestProbe records NotSent, Refused, Held, Landed or Unresolved. A request dropped in flight is Unresolved. classify_failed_request reads the typed object_store error:
    • definite: InMemory, a refusal by the owning storage scope (OWNED_STORE_NAME), not found, an invalid path, an unsupported operation;
    • ambiguous: anything else, including local-filesystem and remote Generic errors and a 403. A Windows manifest rename can create the destination before temporary-file cleanup fails.
  • lance_clone. The configured handler re-issues an ambiguous manifest put up to 4 times, pausing 0.2 s and doubling to 1.6 s. Each re-issue keeps the same manifest and precondition. A held version goes back to Lance as CommitConflict, so verify_commit_outcome compares the complete transaction there and tells this commit from another. probed_commit_handler attaches a probe for one commit.
  • omnigraph-catalog::commit. overwrite_placed commits with a probed handler. It marks a failure Unpublished when no request can land.
  • The publisher. merge_rows reports whether a failure came before or after the store's acknowledgement.
    • Before it: a typed conflict or Unpublished is returned as is, and only an opaque error with no evidence is read back.
    • After it: the version is always read back.
    • Absent on read-back stays in doubt, because a request that may have landed can still land; the old "failed before any store request" heuristic is gone.
    • Failures proven to precede publication are Unpublished, including preparation and history archiving. Candidate failures after tag creation remain in doubt unless captured authority is proven expired; a successful rollback deletion cannot fence an earlier conditional-create retry.
    • A durable commit whose rows name another version is now explicitly Uncertain.
    • A candidate tag whose create may still land is in doubt. The same dedicated in-doubt error survives a failed post-tag authority probe, preserving merge-input holds even when tag rollback succeeds.
  • Logs.
    • An engine failure without definite completion evidence logs its RedactedCause where the server maps it, and a contained owned write logs its status before admission closes. ApiError stays four words; its size guard keeps the handler result frame small.
    • Each re-issue, and a request no re-issue settles, logs the object-store error class (request_error_class).
    • No line carries a storage path or an error message, which can hold URIs.
  • DST. A slot rule, PublicationFault, aims at the two publication requests the seeded weather cannot target: the manifest put-if-absent and the __manifest transaction file. It can fail a request unanswered, throttle it, lose its acknowledgement, or land it late.
  • Docs.
    • A new user page, operations/storage-failures.md, covers publication outcomes and logs; deployment.md is at its 350-line cap and now points to it.
    • writes.md and control-plane.md are updated, and lance.md gets a new fence row.
    • Changelog: publication-failures-before-the-manifest-are-definite.fixed.md.

Tests

On main (9e2425d2), transaction_file_failure_before_the_manifest_is_unpublished_issue_1067 fails with the incident's message (manifest publish outcome is in doubt … _versions/2.manifest was not found); it passes here, as do the rest.

  • Core.
    • manifest_request::tests: an unresolved request is dropped, refusals are told from ambiguous failures, the pauses are bounded, and the logged error class carries no path.
    • lance_clone::tests::manifest_request_is_re_issued_until_the_store_answers_issue_1067.
  • Catalog.
    • transaction_file_failure_before_the_manifest_is_unpublished_issue_1067
    • lost_manifest_acknowledgement_resolves_by_re_issue_issue_1067
    • abandoned_manifest_create_that_lands_late_publishes_once_issue_1067
    • manifest_create_without_a_definite_answer_stays_in_doubt_issue_1067
  • Engine. writes::publisher_failure_before_the_manifest_put_is_definite_issue_1067.
  • Server. boot_settings definite_write_failure_keeps_serving_and_releases_the_lock_issue_1067 plants a fault under a real server's __manifest/_transactions and expects a 500, then a 200 from the same PID.
  • DST. dst_publication_requests_get_a_definite_outcome_issue_1067 runs five cases:
    • a throttled transaction file is Unpublished;
    • a lost acknowledgement, an unanswered request and a request that lands late each commit exactly once;
    • a request no re-issue settles is in doubt, with nothing visible;
    • in every case the next write on the same handle publishes.

Review regression coverage

  • Extended the existing core classifier and handler owners: persist a real native manifest, inject a local filesystem error, and require the identical re-issue to encounter the occupied version rather than claim refusal.
  • candidate_tag_reappearing_after_rollback_stays_uncertain_issue_1067: reuse the publication fault fixture to hold a duplicate conditional tag create, fail the authority read, confirm rollback deletion, then land the earlier create. Both proposal and unattached variants remain uncertain; the proposal can still publish and the snapshot can still be read and extended.
  • The existing merge probe-failure regression retains merge-input holds on the dedicated in-doubt error. Existing head-expiry coverage keeps its definite refusal.
  • Rust owns these regressions because they require storage fault timing, native tag reachability, and completion evidence for an operation returning no candidate id. Healthy-path storage calls are unchanged; no storage-cost golden update is needed.

Checklist

  • Change is focused (one logical change)
  • Tests added/updated for behavior changes
  • Public docs updated
  • Reviewed against invariants. Publication stays one conditional __manifest commit. A re-issue is the same conditional request, logged, bounded and never a replay of a reported outcome. Evidence is only ever narrowed from in doubt to definite where the request record proves it. Explicit uncertainty is never erased.

Review-fix verification

At the reviewed base b017cc39, the new/extended core and candidate tests fail for the predicted reasons: a persisted local manifest is called refused; replayed candidate tags remain publishable/readable but the operation claims Unpublished; the engine releases merge input holds. With these fixes, on macOS arm64 and RUST_MIN_STACK=16777216:

  • Full core/catalog unit suites with omnigraph-catalog/failpoints: 295 passed (82 core, 213 catalog).
  • Full engine failpoint suite: 142 passed, 2 ignored, including merge_as_commit_probe_failure_retains_inputs_issue_1067.
  • cargo fmt --all --check, git diff --check, typos, documentation/link checks, and check-ci-cells.py --self-test pass.
  • Focused core/catalog clippy with failpoints also passes. Fresh CI on the final pushed head must pass before merge, including the cloud storage owners and GQT/DST.

CI security dependency fix

Fresh CI for faeef19e found three newly reported TLS advisories in dependencies already in the lockfile. The follow-up changes only these package versions/checksums and adds a release note:

Cargo resolved the updates within existing dependency constraints; no Cargo manifests, allowlists, or gate rules change. The dependency-source guard and docs checks pass. The full core/catalog unit suites pass again with this patched lockfile (295 tests), as does focused core/catalog clippy with failpoints. The fresh CI dependency guard passes; cargo-deny is not installed locally.

Earlier local verification

The following records the author's validation before these review fixes, with RUST_MIN_STACK=16777216 on macOS arm64.

  • Focused suites. cargo test -p omnigraph-engine -p omnigraph-server -p omnigraph-cluster -p omnigraph-core -p omnigraph-storage -p omnigraph-catalog --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints --locked:
    • catalog 211;
    • cluster 134 + 8 + 2 + 2;
    • core 82;
    • engine: failpoints 141, forbidden_apis 35, integration 636, lance_surface_guards 68, schema_apply 41, search 34, writes 64;
    • server: boot_settings 42, integration 270, multi_graph 8;
    • storage 73.
  • Re-runs after the last commits.
    • server api_error_tests, operations:: and the boot_settings owned_shutdown journeys;
    • core manifest_request and lance_clone.
  • DST, from crates/omnigraph-dst:
    • dst_publication_requests_get_a_definite_outcome_issue_1067, dst_lance_realm_ack_loss_bites_and_oracles_hold, dst_fault_storm_on_one_live_handle_keeps_writing, dst_memory_graph_end_to_end;
    • dst_bench_cost_count_golden, unchanged.
  • Static checks. cargo fmt --all --check; both workspace clippy graphs (with and without failpoints); scripts/check-docs.py; scripts/check-agents-md.sh; typos.

After merging main (Lance 13.0.0, #1072)

  • The handler now wraps main's ManifestLocationCommitHandler; the probe and re-issue are unchanged.
  • I re-read Lance 13's commit path (lance-13.0.0/src/io/commit.rs). It still writes the transaction file first with a bare ?.
  • Its ConditionalPutCommitHandler now reports a missing version as definitive at read-back. A failed manifest put therefore ends at its first read-back, while an abandoned put can still land afterwards. So the re-issue still runs before Lance reads back, and the lance.md fence row says so.
  • Lance 13's WrappingObjectStore also wraps paginated listings; the catalog test fault passes them through (64e7d339).
  • Shared-handler safety (b017cc39): only a per-commit probe can make a missing version definitive. A shared handler never does, so Lance keeps transaction files that a late manifest may name without per-task state or eviction. Regressions: unresolved_manifest_create_keeps_its_transaction_file_issue_1067 and a_shared_handler_never_calls_a_missing_version_definitive_issue_1067.
  • Re-run on the merged tree:
    • six-package suite: 2720 passed, 0 failed (head 11b391f7, with the review fix below);
    • DST: 4 scenarios plus the cost golden;
    • cargo fmt --all --check and both clippy graphs.

Notes for reviewers

  • Containment remains for unresolved publications. A manifest request still unresolved after every re-issue stays in doubt, and the server still contains for it, as today. The RFC proposes replacing that with branch-scoped pending publications, retries by key class, a per-key scope and a lease-guarded lock.
  • Where the re-issue lives. It sits in the commit handler, so it covers every OmniGraph commit (tables' detached manifests too), not only __manifest.
  • Conflicts.

A served write whose publisher failed before its conditional manifest put
was reported in doubt, and the server contained itself. On 2026-10-19 a
throttled `__manifest/_transactions/*.txn` PUT did this: Lance writes the
transaction file before the manifest and returns its error with a bare
`?`, so no manifest request was sent, but the publisher's read-back rule
called absence definite only for a failure before any store request.

Every OmniGraph dataset commits through one configured commit handler.
It now re-issues an ambiguous manifest put-if-absent unchanged, up to four
times, until the store answers it; a held version goes back to Lance's
transaction read-back, which tells this commit from another. A per-commit
probe records whether a request was sent and how it ended.

The catalog publisher derives each failure's completion evidence from that
record and the attempt's phase: no request sent, or one the store refused,
is `Unpublished`; a request that may have applied is read back, and only an
outcome the read-back cannot settle stays in doubt. Every other publisher
failure, candidate writes included, is `Unpublished`; a candidate tag
whose create may still land is in doubt.

The server's `ApiError` carries the engine failure's redacted class, and a
contained owned write logs it before admission closes.
The handler result frame is sized by ApiError, which a guard keeps at four
words. The redacted cause is logged where the engine error is translated,
and a contained owned write logs its status once.
…uests

A slot rule in the Lance-realm store fails, throttles, loses the
acknowledgement of, or lands late the graph's publication requests, which
the seeded weather cannot target. The scenario requires each to end
committed once, unpublished, or in doubt with nothing visible, and the
next write on the handle to publish.
The re-issue logs named the dataset's base path and the store's error text,
which can carry object URIs. They now record the request's object-store
error class, as every other server storage log does.
The deployment page is at its length cap. Storage outcomes get their own
operations page, which the deployment page's shutdown section points to.
…me-from-commit

# Conflicts:
#	crates/omnigraph-core/src/lance_clone.rs
#	docs/dev/lance.md

ragnorc commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

This draft fixes the incident class at the right layer. It should stay a draft until the handler flag and the rebase below are done.

A graph commit is visible only when the next __manifest version is created put-if-absent. Lance writes the transaction file, then calls CommitHandler::commit for the manifest. An error on that file put returns before the call. The probe stays NotSent. overwrite_placed marks the error Unpublished. publish_attempts returns that evidence and skips the read-back. OmniError::unpublished keeps an in-doubt or uncertain error as it already is. The client gets a definite failure. The server keeps serving. The next write on the same handle can publish.

The re-issue of an ambiguous manifest create uses the same bytes and the same put-if-absent. At most one copy applies. A lost acknowledgement that comes back as "already exists" is a conflict, and Lance compares the full transaction. Exhaustion stays in doubt. That part matches the contract.

Blocking

1. An unresolved manifest put still lets Lance delete the transaction file

IndexOriginCommitHandler::is_version_not_found_definitive (crates/omnigraph-core/src/lance_clone.rs, the method that forwards self.inner.is_version_not_found_definitive()) returns the inner handler's answer. The conditional-put handler answers true. After resolve_manifest_request gives up, it records Unresolved and returns the original error. Lance then runs verify_commit_outcome. A missing version plus a definitive not-found becomes CommitOutcome::Absent. The OtherError arm calls cleanup_transaction_file. Lance documents that delete as safe only when the commit is confirmed absent, because a landed manifest names the transaction file by path.

The probe already says the put may still land. This flag is the hook Lance reads. The two disagree.

I reproduced it on this head. A catalog publish answered every manifest create with an ambiguous timeout, and wrote the first create's payload for real five seconds later.

  • At give-up the error was in doubt, and __manifest/_versions/2.manifest was absent.
  • The only transaction file left was the genesis file 0-27d47571-0d84-402a-a839-3cbbb0c62fc4.txn. The attempt's file was already gone.
  • After the late write, version 2 existed. Its manifest named 1-ee57c5b7-6b59-4580-ae32-f39f10c64a4f.txn. That file was absent.

A commit small enough to inline its transaction still opens. Dataset::read_transaction prefers the inline section (20 MiB in production, 64 KiB in Lance tests). A transaction above that limit has no inline copy, so the read fails. The version is published and its transaction file is gone.

The storage scope refuses a later delete of _transactions when the graph is opened with Omnigraph::open_with_io_scope. The server uses that scope. Omnigraph::open (embedded and CLI) and the catalog test store do not. The re-issue sends the same create up to five times (MANIFEST_REQUEST_REISSUES = 4, plus the first send). That widens the window in which a late create can land after the delete. The scope is a server backstop. The commit protocol has to keep the file on every open path.

Fix this in the handler. That type owns the request record. When this attempt ended Unresolved, is_version_not_found_definitive returns false. Lance then treats the missing version as unknown, returns commit_status_unknown, and skips the delete. A Refused attempt, and an attempt that never entered the handler, keeps the inner answer, so Lance still removes an orphan the store has refused.

The catalog path can read the per-commit probe. overwrite_placed builds a fresh handler, and Lance calls the flag on that same handler after commit returns. The dataset-wide handler (wrap_commit_handler, probe: None) also re-issues, and concurrent commits share it. Record the unresolved fact on the attempt that is inside commit. Lance verifies on that same task. A process-wide flag races.

with_skip_auto_cleanup(true) on the catalog builder does not cover this delete. The delete is the explicit cleanup on OtherError plus Absent in commit_transaction (Lance 11 lance/src/io/commit.rs, the OtherError arm; the same arm is still present in Lance 13.0.0).

Suggested inline on crates/omnigraph-core/src/lance_clone.rs at is_version_not_found_definitive: return false when this attempt is Unresolved. Keep the inner answer for a Refused attempt and for an attempt that never entered the handler. The dataset-wide handler has probe: None and is shared. Record the unresolved fact on the attempt's task, because Lance verifies on that same task.

2. Rebase onto current main (85a46138) before this leaves draft

main is on Lance 13.0.0 (#1072). git merge-tree of this head with that main conflicts in:

  • crates/omnigraph-catalog/src/lib.rs
  • crates/omnigraph-catalog/src/tests.rs
  • crates/omnigraph-core/src/error.rs
  • crates/omnigraph-core/src/lance_clone.rs
  • crates/omnigraph-dst/src/lance_faults.rs
  • crates/omnigraph-server/src/lib.rs
  • crates/omnigraph/tests/writes.rs
  • docs/dev/control-plane.md
  • docs/dev/lance.md
  • docs/dev/writes.md

On the Lance 13.0.0 tag, write_manifest_file in lance/src/dataset.rs still calls CommitHandler::commit. supports_predecessor_condition defaults to false, and the default commit_after returns not-supported. The re-issue can stay on commit. The wrapper still has to delegate commit_after, resolve_latest_identity, resolve_identity, and forget_version. ManifestLocation now carries identity. Tests that build a location without that field fail to compile on 13. The new lance.md fence row speaks about Lance 11 and needs the same statement for 13.

#1076 also edits ApiError::from_omni. #1096 is one commit on this head and leaves lance_clone as this PR wrote it, so the delete fix belongs here. #1096 needs the same rebase. #1093 can stay the later RFC for storage-outcome policy. Classifying a request that never left the process does not need that RFC.

Optional

write_candidate and write_unattached_candidate pass the overwrite error through OmniError::unpublished. An unresolved detached manifest stays opaque (may_have_landed, no completion evidence). The outer mark then stamps Unpublished with no read-back. The tag is the only address of that commit, and this PR documents a failure that cannot leave the tag behind as Unpublished. The detached OtherError path runs the same Lance cleanup when not-found is definitive. The handler flag covers that path. When the probe says the request may have landed, leave that evidence on the error so unpublished() preserves it.

Liability

The probe plus the re-issue is the one record of whether a manifest request left the process and what the store answered. Later outcome changes should extend that record. Extra publisher heuristics would each invent a second reading of the same put. Shipping the re-issue while is_version_not_found_definitive ignores the probe leaves Lance as a second authority: it deletes a file a later landing of the same create can name. That cost shows up on every writer that does not use the server scope, and it grows with each new re-issue of the same kind.

Validation

Head: 14461efd5be458789672083edfe6202f3165407f. The checkout was restored to that commit after the runs. No CI result on this head was used. The engine writes test and the server boot test were read and not executed. GQT cannot inject an object-store fault on the transaction-file put, or a manifest that lands after give-up. The catalog tests own this contract.

After, with the Unpublished mark present:

RUST_MIN_STACK=16777216 CARGO_TARGET_DIR=/tmp/og-review/target \
  cargo test -p omnigraph-catalog --offline --lib -- \
  transaction_file_failure_before_the_manifest_is_unpublished_issue_1067 \
  -- --test-threads=1 --nocapture

transaction_file_failure_before_the_manifest_is_unpublished_issue_1067 passed (omnigraph_catalog-41a2638d6974b337). The same process also ran a temporary late-land case that is not in this PR. That case failed with the evidence in blocking finding 1.

Before, same head, with the NotSent / Refused mark withheld in overwrite_placed, so a storage error stayed opaque. That is how this failure was treated before the fix. Base 9e2425d2 was not compiled: the regression test does not exist there.

RUST_MIN_STACK=16777216 CARGO_TARGET_DIR=/tmp/og-review/target \
  cargo test -p omnigraph-catalog --offline --lib -- \
  transaction_file_failure_before_the_manifest_is_unpublished_issue_1067 \
  -- --exact --nocapture

Failed at crates/omnigraph-catalog/src/tests.rs:14994. The message was: no manifest request was sent: manifest publish outcome is in doubt: ... __manifest/_versions/2.manifest was not found ... original error: storage: LanceError(IO): File exists (os error 17). That is the predicted pre-fix class. An opaque storage error plus an absent version becomes in doubt. The local setup plants a file where _transactions must be a directory, so the transaction-file put fails with File exists before the handler. The control flow is the same as a remote 503 on that put. The test does not send an S3 503.

Recommendation: return false from is_version_not_found_definitive for an Unresolved attempt, and rebase onto 85a46138, before this leaves draft.

…nce 13

Lance 13's WrappingObjectStore also wraps paginated listings; the fault
aims at puts only.
…t may land

After the last re-issue the handler returned the store's error and kept
reporting a missing version as definitive, so Lance read the version back
as absent and deleted the attempt's transaction file. A manifest that lands
later then names a file that is gone. Only the server's storage scope held
that delete back; embedded and CLI opens did not.

The handler now records the task whose request no re-issue settled, the task
Lance reads the version back on, and reports a missing version as not
definitive there. Lance returns CommitStatusUnknown and keeps the file. A
refused request and a commit that sent none keep the inner answer.
@ragnorc

ragnorc commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Both blocking findings are done; head 11b391f7.

1. Unresolved manifest put let Lance delete the transaction file. Fixed in the handler (11b391f7).

  • ManifestLocationCommitHandler::is_version_not_found_definitive now returns false when the commit's probe is Unresolved. That covers the catalog's per-commit handler.
  • It also returns false when the current tokio task is marked unresolved, which covers a dataset's shared handler (probe: None). The task that sent the last re-issue gets the mark, and Lance reads back on that task.
  • So Lance returns CommitStatusUnknown and skips cleanup_transaction_file. That error still maps to an opaque storage failure, so the publisher's read-back and in-doubt answer are unchanged.
  • A refused request, and a commit that never reached the handler, keep the inner answer, so Lance still removes an orphan the store refused.
  • Marks are bounded at 1024, oldest first. Another attempt never clears one, because it could run between this attempt's commit and its read-back. A stale mark only makes a later failed commit on the same task keep its file.
  • Regression tests:
    • catalog unresolved_manifest_create_keeps_its_transaction_file_issue_1067 is your repro: every create times out, and the first one lands after give-up, with no storage scope. It fails without the fix ("the transaction file 1-….txn named by the late manifest is kept") and passes with it.
    • lance_clone::tests::manifest_request_is_re_issued_until_the_store_answers_issue_1067 now also asserts the flag for each outcome.
    • an_unresolved_request_keeps_its_transaction_file_on_a_shared_handler_issue_1067 checks the per-task mark on a shared handler.
  • lance.md, writes.md and control-plane.md say the scope's delete hold is now the server's second line.

2. Lance 13 main. Done by merging main (3c4e5320), since the fork-only rule has PR branches updated by merge rather than rebase.

  • The handler now wraps main's ManifestLocationCommitHandler, which already delegates commit_after, resolve_latest_identity, resolve_identity, forget_version and supports_predecessor_condition. The re-issue stays on commit.
  • Test locations carry identity: None. The test fault implements wrap_paginated (64e7d339).
  • The lance.md fence row is restated for 13: ConditionalPutCommitHandler::is_version_not_found_definitive is now true, which is why the flag above matters.

Optional, write_candidate and write_unattached_candidate: left as is.

  • The tag is the candidate's only address, and create_candidate_tag runs only after overwrite_detached returns Ok.
  • An unresolved detached put returns before any tag request, so it cannot leave the tag behind. A late landing is a detached __manifest version nothing names, unreachable like any unpublished staging, and the collector reclaims it.
  • With the flag, Lance keeps its transaction file on that path too.
  • Marking it uncertain would report a proposal "in doubt" that can never become addressable. An ambiguous tag create is still in doubt (create_candidate_tag).

Validation on 11b391f7 (RUST_MIN_STACK=16777216):

  • six-package suite with failpoints: 2720 passed, 0 failed;
  • DST: dst_publication_requests_get_a_definite_outcome_issue_1067, the ack-loss, fault-storm and end-to-end scenarios, and the cost golden;
  • fmt and both workspace clippy graphs;
  • check-docs.py, check-agents-md.sh, typos.

@ragnorc
ragnorc marked this pull request as ready for review October 20, 2026 16:35

ragnorc commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Ready to merge. There are no blocking findings. There is one optional finding.

A graph commit becomes visible only when the next __manifest version is created with put-if-absent. Lance writes the transaction file first. It then calls the commit handler for the manifest. On 2026-10-19 the transaction-file put was throttled. That error returned before any manifest put. The publisher still treated a missing version as uncertain. The server stopped and held its lock.

This change keeps one record per commit of whether a manifest request was sent and how it ended. The handler sends an unanswered request again, with the same bytes and the same put-if-absent, up to four times. At most one copy can apply. No request, or a request the store refused, is Unpublished. The client receives that failure and the server keeps serving. A request that may still apply is read back. Only an outcome the read-back cannot settle stays in doubt. After the last re-issue, a missing version is not proof that the commit is absent. Lance then returns CommitStatusUnknown and keeps the transaction file that a late manifest names.

The record lives on the commit handler. Every dataset commits through that handler. The catalog publisher reads the record. It does not infer the outcome from the error text.

Earlier findings

1. An unresolved manifest put let Lance delete the transaction file. Resolved.

ManifestLocationCommitHandler::is_version_not_found_definitive returns false when this commit's probe is Unresolved, and when the current task is marked. In Lance 13.0.0, verify_commit_outcome then returns CommitOutcome::Unknown. The OtherError arm returns CommitStatusUnknown and does not call cleanup_transaction_file. A refused request, and a commit that sent no manifest request, still use the inner answer. Lance still deletes an orphan the store has refused.

On this head I made that method return the inner answer. unresolved_manifest_create_keeps_its_transaction_file_issue_1067 failed. The late manifest named 1-b690a4c2-cad2-4580-9399-2382af317e22.txn, and that file was already gone. With the method restored, the same test passed.

2. Update onto Lance 13 main. Resolved.

The branch contains the merge of 85a46138. In Lance 13.0.0, ConditionalPutCommitHandler::is_version_not_found_definitive returns true. The wrapper delegates commit_after, resolve_latest_identity, resolve_identity, forget_version, and supports_predecessor_condition. main is now b692610c, five commits ahead of this branch. git merge-tree --write-tree of this head with that main succeeds. Those commits thread sessions and explain plans. They do not change this publication contract.

3. write_candidate and write_unattached_candidate mark an unresolved detached put Unpublished. Resolved by design.

The tag is the only address of that commit. create_candidate_tag runs only after overwrite_detached returns success. An unresolved detached put returns before any tag request. A late landing is a detached __manifest version that no tag names. list_unpublished finds proposals by tag. The handler flag keeps the transaction file on this path too. An in-doubt error here would stop the server for a version that cannot become addressable.

Blocking

None.

Optional

The shared dataset handler has probe: None. It stores at most 1024 task ids (UnresolvedTasks). The comment on that type says a read-back that follows its commit never reaches the bound. Lance does not call the flag at the return of commit. verify_commit_outcome reads the manifest first, and that read awaits. Other tasks can record ids during that await. The 1024th newer id drops this task. The flag then follows the inner handler, which returns true, and Lance deletes the transaction file.

The catalog path does not use this queue. overwrite_placed passes a probe, and the flag reads probe.outcome(). That is the path of the 2026-10-19 failure.

A mark that outlives its attempt only makes a later failed commit on that task keep an orphan transaction file. Cleanup can reclaim an orphan file by age. A caller with no task id shares one slot, with the same effect.

Keep a task id that may still be inside the read-back. The catalog probe can stay as it is.

Inline, crates/omnigraph-core/src/lance_clone.rs line 113, UnresolvedTasks::mark_current: this eviction can drop the task Lance is still reading back. contains also returns without moving an existing id to the back, so an older mark is dropped first. try_id() of None shares one slot, and that slot only keeps a later orphan file.

Tradeoff and liability

The probe is the one record of the manifest request. Later outcome work should extend that record. A second reading in the publisher would drift from it.

The re-issue adds up to four identical puts, and about 3 seconds of pause (0.2 s, then double, to 1.6 s), before an unresolved publish stays in doubt. The server still stops for that case. #1093 decides what replaces that stop. #1096 contains this head (5 commits ahead, 0 behind) and should merge only after that RFC. #1097 is based on #1096. #1017 does not change this contract. #1067 stays open for the RFC. #1070 remains the earlier rule that a definite failure before publication does not stop the server.

Validation

Head: 11b391f7880deb1a812cc2e77f30b54abc25a66b. The checkout was isolated. Temporary edits were restored. The checkout was still that commit, and the worktree was clean.

GQT cannot plant an object-store fault on the transaction-file put, and it cannot land a manifest after give-up. The catalog tests own this contract. No GQT case was added.

After, on the head (RUST_MIN_STACK=16777216, CARGO_TARGET_DIR=/workspace/target):

cargo test -p omnigraph-catalog -p omnigraph-core --offline --lib -- \
  unresolved_manifest_create_keeps_its_transaction_file_issue_1067 \
  transaction_file_failure_before_the_manifest_is_unpublished_issue_1067 \
  manifest_create_without_a_definite_answer_stays_in_doubt_issue_1067 \
  lost_manifest_acknowledgement_resolves_by_re_issue_issue_1067 \
  abandoned_manifest_create_that_lands_late_publishes_once_issue_1067 \
  manifest_request:: \
  lance_clone::tests::manifest_request_is_re_issued_until_the_store_answers_issue_1067 \
  lance_clone::tests::an_unresolved_request_keeps_its_transaction_file_on_a_shared_handler_issue_1067 \
  -- --test-threads=1

5 catalog tests passed. 5 core tests passed.

Before, same head, two temporary source edits. Nothing was pushed.

  • is_version_not_found_definitive returned the inner answer. unresolved_manifest_create_keeps_its_transaction_file_issue_1067 failed at crates/omnigraph-catalog/src/tests.rs:15157. The late manifest's transaction file was already gone.
  • overwrite_placed did not mark a NotSent or Refused outcome Unpublished. transaction_file_failure_before_the_manifest_is_unpublished_issue_1067 failed at crates/omnigraph-catalog/src/tests.rs:15059. The error was manifest publish outcome is in doubt because _versions/2.manifest was not found. The original error was File exists (os error 17).

Limitation: that second test plants a file where _transactions must be a directory. Lance writes a local transaction file past the object-store wrapper, so the put fails with File exists before the handler. The test does not send an S3 503. The probe stays NotSent either way, so the publisher takes the same path.

Both tests passed again after the source was restored.

CI on this exact head is green (Actions run 39458692106, created 2026-10-20T16:34:52Z). That includes Test Workspace, clippy, rustfmt, the DST pinned suite, GQT, RustFS, and Azurite. I did not re-run those jobs. I did not run the engine writes test or the server boot test locally.

Lance source checked: crates.io lance 13.0.0 and lance-table 13.0.0. commit_transaction writes the transaction file with ? before CommitHandler::commit. cleanup_transaction_file runs for a failed attempt only when verify_commit_outcome returns Absent or Foreign. Its comment says callers must use it only when the commit is confirmed absent.

Recommendation

Ready to merge.

Lance deletes an attempt's transaction file when the handler calls a
missing version definitive, and reads that answer with no reference to
the attempt. The per-task marks that stood in for one relied on Lance
reading the version back on the task that sent the request.

The catalog's per-commit handler now answers from its probe, keeping the
inner answer unless the request may still land. A dataset's shared handler
holds no record of the attempt Lance reads back, so it never calls a
missing version definitive; Lance reports the status unknown and the
collector's orphan sweep reclaims a table's leftover transaction file.
@ragnorc

ragnorc commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up to the flag fix, now at head b017cc39: the per-task marks are replaced by a rule with no state.

Why. is_version_not_found_definitive(&self) gives the handler no reference to the attempt being read back. The per-task marks stood in for that reference. They relied on Lance reading the version back on the same tokio task that called commit. That holds in Lance 13 (commit_transaction awaits write_manifest_file and then verify_commit_outcome in sequence), but no test pinned it at the Lance boundary for the shared handler.

Rule. Only the commit's own request record may call a missing version definitive.

  • The catalog's per-commit handler answers from its probe. It keeps the inner answer unless the request may still land, so a refused catalog commit still lets Lance remove its orphan. That matters because nothing collects orphans under __manifest.
  • A dataset's shared handler holds no record of the attempt, so it never calls a missing version definitive. Lance then returns CommitStatusUnknown (still an opaque storage error, so the evidence is unchanged) and skips cleanup_transaction_file.
    • The cost: a table commit the store refused outright leaves its transaction file to the collector's orphan sweep (orphan_key covers _transactions/*, seven-day age), and its failure takes Lance's ~150 ms of verification reads.
    • This changes main's creation_preserves_configured_handler_session_and_failure_semantics assertion for a shared configured handler, deliberately and with a comment.

Validation of the Lance 13 assumptions.

  • is_version_not_found_definitive is read only in verify_commit_outcome.
  • On the new-dataset, linear and detached paths, Unknown returns commit_status_unknown without cleanup_transaction_file.
  • Foreign still cleans up, because a version another commit holds can never take this attempt's request.

Tests.

  • lance_clone::tests::a_shared_handler_never_calls_a_missing_version_definitive_issue_1067, after landed, refused and unresolved commits.
  • manifest_request_is_re_issued_until_the_store_answers_issue_1067 asserts the probed handler's answer for each outcome.
  • Catalog unresolved_manifest_create_keeps_its_transaction_file_issue_1067 is unchanged and green.

Run on b017cc39:

  • six-package suite with failpoints: 2720 passed, 0 failed;
  • DST, adding dst_crash_state_enumeration_sampled: 6 passed;
  • fmt, both clippy graphs, check-docs.py, typos.

@ragnorc

ragnorc commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

@aaltshuler thanks: both commits are kept, and both fit the design, which gives each fact about a request one owner.

  • faeef19e, local errors ambiguous. This is right beyond Windows. In object_store 0.14.2, LocalFileSystem's put-if-absent hard-links the destination and then fsyncs the parent directory. That fsync can fail after the manifest exists, and the local error variant that would tell the two apart isn't public. Letting the identical re-issue decide is the robust rule, and it drops a store-name check.
  • faeef19e, candidate tags in doubt after release. Also right. The version-key fence holds only for keys nothing deletes, and tags are deleted on release, so only a moved authority or a deleted branch proves expiry.
  • 0165f7db, Cargo.lock bump. This is what cargo-deny needed on every branch.

Both are merged into #1096 (0fffdf24) and #1097 (b8f9813a); those branches pick up #1092 by merge. To avoid colliding: I push to this branch only by fast-forward on top of yours, and I'm not changing the publisher or manifest_request right now.

ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 20, 2026
Storage outcomes per operation dropped its pending-publication registry:
an unknown publication is recorded nowhere and blocks nothing, and its
version key fences it. A control fact follows the same fence, and the
one-branch service_unavailable row goes. Read-back follows ModernRelay#1092's
split, a not-ready branch's absent listing fields are named, and the
access evidence uses RFC 0059's target kinds.
@aaltshuler
aaltshuler added this pull request to the merge queue Oct 20, 2026

ragnorc commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Ready to merge. There are no blocking findings and no optional findings.

Why

A graph commit is visible only when the next __manifest version is created with put-if-absent. Lance writes the transaction file first, then calls the commit handler. On 2026-10-19 a throttled transaction-file put returned before any manifest put. The last review left one optional risk: the shared handler kept at most 1024 task ids, and an eviction could let Lance delete a transaction file it was still reading back.

What changed

b017cc39 deletes that queue. The flag is now a stateless rule. faeef19e keeps a local-filesystem error and a released candidate tag in doubt. 0165f7db bumps the lockfile for three certificate-chain advisories. 1070bfa9 merges main (b692610c). The publication files are unchanged by that merge.

The rule

ManifestLocationCommitHandler::is_version_not_found_definitive (crates/omnigraph-core/src/lance_clone.rs) answers from the probe when the handler was built for one commit. An unresolved probe returns false. A shared handler has no probe and always returns false.

Lance 13.0.0 reads this flag only inside verify_commit_outcome, on the same handler, after write_manifest_file returns. That is true for a new dataset, a linear commit, and a detached commit. The flag is a property of the handler object. Concurrent commits do not share a catalog probe. A shared dataset handler cannot answer true for any task, so a cross-task read-back cannot delete the file.

A false answer makes Lance return CommitStatusUnknown and skip cleanup_transaction_file. Foreign still deletes, because another commit owns that version.

The catalog builds its handler with probed_commit_handler, so the inner handler is the stock conditional-put handler. A refused catalog commit still lets Lance remove the orphan. Nothing collects orphans under __manifest.

A shared handler's refused commit also keeps the file. commit_detached maps that error through OmniError::storage(error).unpublished(), so the server keeps serving. The collector deletes an unreferenced _transactions file after seven days (UNVERIFIED_THRESHOLD_DAYS). The extra cost is Lance's two verification sleeps (about 0–100 ms, then about 50–150 ms). That cost is accepted and documented in docs/dev/lance.md.

Local errors and candidate tags

classify_failed_request treats a generic LocalFileSystem error as ambiguous. In object_store 0.14.2, put-if-absent hard-links the destination and can fsync the parent afterwards. The fsync can fail after the file exists, and the public error does not separate that case from a create that never happened. OmniGraph does not enable fsync. Treating every local generic error as ambiguous is the fail-closed rule. A re-issue of the same bytes hits AlreadyExists when the file did land, and Lance compares the transaction.

After a candidate tag exists, a failed authority probe stays in doubt unless the read set changed or the branch is gone. candidate_probe_failed_after_release does that. A deleted tag can be created again by a delayed conditional put. Merge keeps input holds when the error is manifest_publish_in_doubt. A probe that succeeds and then sees a moved head still returns manifest_read_set_changed.

Dependencies

0165f7db moves rustls-webpki to 0.103.16, webpki-roots to 1.0.10, and webpki-root-certs to 1.0.10. Those floors match RUSTSEC-2026-0381, RUSTSEC-2026-0382, and RUSTSEC-2026-0383. cargo deny has no ignore for them. The bump does not change publication behavior. It belongs on this branch so the deny gate stays green. CI Dependency Guard (cargo deny) passed on this head.

Earlier findings

  • Unresolved manifest put deleted the transaction file: resolved. The stateless rule still returns false for an unresolved probe and for every shared handler.
  • Rebase onto Lance 13 main: resolved. This head merges b692610c.
  • write_candidate stamped Unpublished on an unresolved detached manifest: resolved before 11b391f7.
  • Shared-handler queue of 1024 task ids: resolved. UnresolvedTasks is gone.

Liability

One request record owns each fact. The catalog probe owns the catalog answer. A shared handler owns no attempt, so it refuses to call a version absent. Later outcome work should extend that record. A second heuristic on the publisher would read the same put twice.

The accepted cost is orphan transaction files on refused table commits, until the seven-day sweep, and a short verification retry. An ambiguous local error can stop the server. That is the safe side of the 2026-10-19 class.

Validation

Publication logic was executed at 0165f7db. 1070bfa9 does not change lance_clone.rs, manifest_request.rs, publisher.rs, commit.rs, the catalog tests, or the failpoints test. The merge edits merge.rs only to open the base snapshot with the control session. table_store.rs still builds the commit handler with configured_commit_handler after lance_store_params_for_session. That helper returns store parameters. It does not install a raw handler.

After, clean 0165f7db:

RUST_MIN_STACK=16777216 CARGO_TARGET_DIR=/workspace/target \
  cargo test -p omnigraph-catalog -p omnigraph-core --lib -- \
  unresolved_manifest_create_keeps_its_transaction_file_issue_1067 \
  candidate_tag_reappearing_after_rollback_stays_uncertain_issue_1067 \
  transaction_file_failure_before_the_manifest_is_unpublished_issue_1067 \
  manifest_request:: \
  lance_clone::tests::manifest_request_is_re_issued_until_the_store_answers_issue_1067 \
  lance_clone::tests::a_shared_handler_never_calls_a_missing_version_definitive_issue_1067 \
  -- --test-threads=1

Exit 0. Catalog: 3 passed. Core: 5 passed. The same command passed again after the temporary edits were restored.

Before, same commit, temporary source edits, nothing pushed:

  • The shared-handler arm returned the inner answer. a_shared_handler_never_calls_a_missing_version_definitive_issue_1067 panicked at lance_clone.rs:811 with [Landed].
  • LocalFileSystem was classified Refused. manifest_request_is_re_issued_until_the_store_answers_issue_1067 panicked at lance_clone.rs:770: left (false, 1, Refused), right (false, 2, Held). only_a_definite_refusal_rules_out_a_late_landing_issue_1067 expected Ambiguous and saw Refused.
  • A successful tag delete returned the raw probe error. candidate_tag_reappearing_after_rollback_stays_uncertain_issue_1067 panicked at tests.rs:15352: left [(false, Some(Unpublished), false), (true, Some(Unpublished), false)], right [(false, Some(Uncertain), true), (true, Some(Uncertain), true)].

GQT cannot plant the store fault, a late manifest, or completion evidence for an operation that returns no candidate id. No GQT case was added.

I did not run merge_as_commit_probe_failure_retains_inputs_issue_1067. I read it. It expects CompletionEvidence::Uncertain and retained merge-input tags. I did not re-run the workspace suite.

CI on 1070bfa9 is green, including CI Gate, Test Workspace, DST pinned suite, GQ Logic Tests, and Dependency Guard. Devin Review skipped because the trial has no credits. That is not a code review of this head.

Recommendation

Ready to merge.

Merged via the queue into ModernRelay:main with commit d4aa7fe Oct 20, 2026
32 checks passed
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 21, 2026
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 21, 2026
* docs(rfc): propose local graph sessions and client access

Draft RFC for issue ModernRelay#980: `omnigraph serve` local sessions with session
records, a thin `omnigraph studio` launcher, `omnigraph mcp` for agents,
and `omnigraph credential` for static tokens. Wider-blast-radius changes
are listed in Appendix A with their filed issues.

* docs(rfc): add installing Studio as a later, signature-gated phase

* docs(rfc): link Studio's release prerequisites and correct their current state

* docs(rfc): reconcile local sessions with main and open PRs, and define the target writer

* docs(rfc): name local servers and settle records, tokens and agent paths

Rename the session to a local server and --session to --via-server.
Move server records from /tmp, which macOS ages out after three days,
to per-user directories resolved from the account, with the machine
they belong to. Never write a token to a terminal, list live local
servers in targets --json, give each agent context one write path,
add platform coverage, and gather every refusal code in one table.

* docs(rfc): redesign graph access around seven rules and fold in cross-process writes

Rewrite the RFC from first principles: name the target, never the path;
correctness in the engine across processes; one set of operations on three
surfaces; one serving concept with explicit audiences, folding the
omnigraph-server binary into omnigraph serve; --as as a declaration or an
assertion; one outcome model and exit table; and introspection through
target list and access. Bring cross-process writer safety and the access
route into scope, with the engine design on the __manifest version CAS,
and land the interfaces before the engine phase. Rename the file to match
the new title.

* docs(rfc): align outcomes, serving lifecycle and late writes with the storage-uncertainty fix

* docs(rfc): elect one local server per destination and close the reuse, handoff and MCP gaps from review

* rfc: align graph access with the phase 1 and phase 3 implementations

The actor header rides the HTTP contract bump to 0.14 instead of a
capability response header, and /mcp compares it after authentication
alone. Rule 6 states the outcome table the server implements, with an
earlier publication marked on the engine's error and outcome.check waiting
for an attempt id. The local embedding API names what the implementation
settled: LocalBinding::local for local roots, ReadWriteUnavailable,
ClusterGraph instead of promoting a graph to its cluster, start_local and
LocalServing::release, and one captured ledger revision per read-only
cluster server.

* rfc: align graph access with the disposition, the If-Actor header and the records' machine id

Phase 1's review renamed the error object and the actor header, and
phase 3's review tightened the records' machine id. The RFC now says
what the implementations do:

- The actor header is Omnigraph-If-Actor, after HTTP's conditional
  If-* headers, since a mismatch is a failed condition (412).
- The error object is `disposition`: `execution` is gone because it
  only repeated `effects`; `effects` gains `partial` for a refused
  step after an earlier publication of the same operation; `action`
  gains `fix` for a request that fails the same way on resend. Each
  code names its own action, and partial effects exit 2.
- A record's machine is 32 lowercase hexadecimal digits that are not
  all zeros. Any other value, systemd's first-boot `uninitialized`
  marker included, refuses rather than falling back to another source.
- Windows records wait for their own pull request: until they check
  the directory's access list and read MachineGuid, they refuse.
- The CLI's MCP bridge bullet lists what the bridge actually changes:
  the graph binding, the readOnlyHint filter and local cancellation.

* rfc: decide branch control through control facts each publish attempt reads

The review of c4641d7 found the engine section did not match the
publisher on main: a row change must record a graph commit, and the
preconditions read only the head and branch identity, so a
head-preserving tombstone or drop record stopped nobody.

- Control facts (tombstone, registration, drop record) change no table
  row and record no commit; check_publish_precondition reads the facts
  that name an attempt on every attempt, for every precondition.
- A create registers on main before it writes its ref, so the
  registration is the visibility point and cleanup reads it instead of
  an age. Until PR 963 lands, a registration refuses when main's
  contract moved past its fork; after it, PR 963's rule wins.
- Only the upgrade census raises the new stamp.
- RFC 0059 (PR ModernRelay#1090) owns addressing and the target and credential
  commands; deployments pass their root through --target.
- The access route is ModernRelay#1073's; this RFC adds the command. A direct
  graph inside a cluster refuses and names the root.
- Storage outcomes are PR 1093's; storage_unavailable goes, and
  disposition.check is a later phase.

* docs(rfc): discontinue the second serving model

* docs(rfc): record MCP bridge closure and defer selective reuse

* rfc: treat RFC 0059's implementation as merged

ModernRelay#1090 landed on main, so the RFC no longer waits on it: blocked_on is
empty, and the Motivation, rollout and Appendix A5 say the target
commands build on main.

* rfc: accept graph access and cross-process writes

Maintainer decision: the actor assertion, the error-code registry and
disposition, the exit table, the access command, operation parity, the
existing-server MCP bridge and Studio opener, and the engine control-fact
design with its qualification gates. Appendix A stays separate decisions.

* rfc: give every control fact its attempt id and gate the upgrade on registrations

The review of eeb6e12 found two holes in the engine section:

- A control fact records no graph commit, so the head-equality read-back
  cannot tell two facts apart after a lost acknowledgement, and the
  owner's own retry met its refusal. Each control-fact operation now mints
  one attempt id that every fact it writes records: the read-back compares
  it, a pending control fact is keyed by it, and a retry treats its own
  fact as landed. Deletes and drops converge.
- System-column upgrade pins main's head, not its version, so a
  registration did not stop it. Its attempts now read main's
  registrations every attempt, until PR 963.

Cleanup's registration rule now covers refs as well as trees, under the
collector's dependency closure; a create no longer completes a crashed
create's registration; the pending-branch service_unavailable covers one
branch; branch list shows a registered branch without a ref as not ready;
the --as help quote is current.

* rfc: name the access command's test owner and target terms

* rfc: settle a control fact from the fact, and keep the collector's keep set

The review of ef44175 recommended merging, with four optional
corrections: read-back has three results; a caller settles a control
fact from the fact, since the head does not move; cleanup candidates
still pass protected_manifest_branches and retained_merge_bases; a crash
loses the attempt id, so only delete and drop converge after one. A
not-ready branch's listing fields come from its registration.

* rfc: fence an unknown control fact by its version key

Storage outcomes per operation dropped its pending-publication registry:
an unknown publication is recorded nowhere and blocks nothing, and its
version key fences it. A control fact follows the same fence, and the
one-branch service_unavailable row goes. Read-back follows ModernRelay#1092's
split, a not-ready branch's absent listing fields are named, and the
access evidence uses RFC 0059's target kinds.

* rfc: align the access view field and disposition.check with actor views

* rfc: settle a control fact when its attempted version is readable

---------

Co-authored-by: aaltshuler <andrew@collectivelab.io>
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.

2 participants