Skip to content

fix(server): keep serving after a definite storage failure before publication - #1070

Merged
aaltshuler merged 8 commits into
ModernRelay:mainfrom
ragnorc:claude/omnigraph-issue-1067-3daf2c
Oct 18, 2026
Merged

aaltshuler merged 8 commits into
ModernRelay:mainfrom
ragnorc:claude/omnigraph-issue-1067-3daf2c

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

What & why

A served write could fail before graph publication, poison its storage scope, and shut down the entire server. This fixes the containment part of #1067: a definitely unpublished write returns its failure while admission stays open. Genuinely uncertain publication still contains the process and retains its lock.

The engine records CompletionEvidence::Unpublished at staged-write and detached-commit sites and across the mutation, load, Blob, and merge envelopes. Typed refusals and existing uncertainty retain their meaning. The HTTP boundary reads this evidence independently of the 500 status; the publisher still owns read-back and publication uncertainty.

Storage accounting now distinguishes fresh immutable Lance 11 files from mutable keys and conditional replacements. The classification is anchored to known path tails. Rename accounts for source deletion, and recursive deletion stays conservative. An unacknowledged manifest put, copy, rename, or multipart completion sets one bounded, sticky transaction-retention flag. Successful abort cannot erase an earlier unknown completion. A replacement scope may reclaim retained files only through normal collector authority, reachability, and age checks.

The change preserves the original PR's commits and adds the cancellation, source-delete, conditional-update, prefix-classification, and multipart regressions found during review. It does not add retries, change leases, or broaden recovery. Other opaque schema/maintenance preparation failures remain conservative.

Backing issue / RFC

Addresses the definite pre-publication failure path in #1067. Other recovery work in that issue remains outside this change.

Checklist

  • Change is focused on this release-correctness boundary
  • Existing test owners extended and behavior regressions covered
  • Current documentation and permanent release notes updated
  • Architectural invariants reviewed; no weaker publication or deletion contract

Local verification

The original real-server regression was reproduced on main: the first storage failure returned 500 and the next connection was refused. Eleven added scoped regressions also failed against the original PR before correction.

  • cargo test -p omnigraph-storage --locked scoped — 23 passed, no ignored tests.
  • The common package selection below was used for the remaining focused commands:
    cargo test -p omnigraph-engine -p omnigraph-server -p omnigraph-cluster \
      -p omnigraph-core -p omnigraph-storage \
      --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints --locked
  • Add --lib error::tests — 19 core tests passed.
  • Add --lib operations::tests — 14 server tests passed.
  • Add --test boot_settings owned_shutdown — 15 passed, one subprocess helper ignored. The real failure journey now returns 500, accepts the next write, serves reads, and releases its lock on clean shutdown; existing uncertain-outcome containment journeys still pass.
  • Add --test lance_surface_guards --test writes --test detached_commit_matrix — 68, 63, and 29 passed respectively. Four existing entries remain ignored: two subprocess helpers, an unsupported-on-Lance11 guard, and a heavy 8192-statement reproduction.
  • Total: 231 focused tests passed, five existing ignored entries not counted as passes.
  • cargo fmt --all --check, documentation checker (338 files), AGENTS/index, and whitespace checks — passed.

Full workspace tests and Clippy are left to this updated PR's CI; the local results above are focused qualification.

The three updated branches (#1070, #1071, #1036) also merge cleanly. Their combined code passed five file/scoped-file/S3/Azure collector owners without skips, the real server failure journey, range unit owners, both setup/dead-staging DST pins with replay, and all four range-filter GQT cases. Combined documentation (341 files), formatting, links, required CI names, and merge-group checks passed. Local validation is retained on codex/release-014-integrated-validation; the final additional merge changes only the clarified testing-guide wording.

ragnorc and others added 2 commits October 18, 2026 05:36
…nRelay#1066)

* query: refuse full-text calls without a full-text index (T27, FullTextIndexRequired)

A full-text call (`search`, `fuzzy`, `match_text`, `bm25`) reads its
property's full-text index, whose analyzer folds case and stems; without
one Lance scans with a bare, case-sensitive tokenizer, so the same call
answered differently on an indexed and an unindexed property.

- T27 at type checking: a full-text call on a String node property with no
  one-column full-text `@index`/`@key` is refused, from the schema alone.
  `PropType::index_kind` moves to the compiler and index maintenance uses
  it, so the type checker and the index builder cannot drift. An enum's fix
  points at `=`; a non-String field keeps its operand error.
- FullTextIndexRequired at planning (HTTP 409, `full_text_index_required`
  in the error body): the planner reads the coverage of every property a
  full-text call names, filters included, and refuses `absent`. An
  untrained segment, which every write now declares, is `partial` and
  answers; a table with no fragment, or a type the snapshot does not hold,
  counts as covered. Acceptance refuses a plan that records no coverage,
  or `absent`, for a call, and replay re-derives every recorded coverage
  from the pinned dataset.

Regressions: cases/v2/issue_747_full_text_calls_need_a_full_text_index.gqt
and search.rs::a_full_text_index_with_no_segment_refuses_the_call_issue_747,
both red with their check removed.

* docs: rescope RFC 0047 step 3 and document the full-text index requirement

RFC 0047 records step 3 as refusing only an index with no segment, now
that every write declares a declared column's analyzer, with a
decision-log entry, and its rollout table records steps 2 and 2a as
merged. The search guide, troubleshooting page and execution guide
describe T27 and the 409; the release notes call T27 breaking and tell
operators to validate stored queries before upgrading.
…lication

A served write that fails after staging its detached table effects and
before its publication left the process: the engine returned a bare storage
error, `ApiError::internal` treats every 500 as an unknown outcome, and the
owned-write runtime contained the process with the cluster lock held.

Add `CompletionEvidence::Unpublished` and attach it in the mutation, load,
Blob and merge writers from the first staged file to the publisher call,
and in the reads before `publish_prepared`. The server reads it as definite,
like `BeforeEffect`; a write error without evidence still fails closed.

Classify every request the storage scope accounts for by the key it names:
Lance's write-once table files and put-if-absent manifest versions settle
with their definite error, since a late or duplicate landing is unreachable
or refused; refs, the version hint, control objects and unknown keys keep
the sticky rule. Log the first request whose outcome is unknown with its
key and error, and every definite failure of an immutable key.
ragnorc and others added 2 commits October 17, 2026 22:37
* feat(query): the ranked binding roots its component

A nearest(), bm25() or rrf() order on a binding a traversal reaches was
refused at planning unless that binding was declared first, and a ranked
binding no declaration introduced could not be ranked at all.

Lowering now roots the top-level component holding the bindings the
leading order key ranks at that binding; a ranked binding only a
traversal introduces gets a NodeScan of its checked type, and every
other binding of the component is reached by a forward or reverse
expand. An rrf() whose arms rank two bindings one traversal connects is
the type error T59. The planner codes P001 and P005 are withdrawn; the
planner keeps both checks as internal errors.

* docs(search): rank a traversal-reached binding

ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

This change lets the server stay in service after a storage failure that happens before publication. The failure is definite. The graph did not move. Admission stays open. A clean shutdown can release the cluster lock.

CompletionEvidence::Unpublished is the mark. Mutation, load, Blob, and merge attach it from the first staged file to the publisher call. table_ops attaches it to the reads before publish_prepared. ApiError::from_omni treats the mark as definite. The storage scope settles a failed request when the key is a Lance write-once file or a put-if-absent manifest.

The incident in #1067 is a lost acknowledgement on a detached table manifest. That object is small. Lance writes it with one PUT. This change does settle that request, and the server can keep serving. Two holes remain. Both break the settlement rule in this pull request.

Recommendation: request changes.

Blocking

  1. A lost acknowledgement on a manifest put lets Lance delete the transaction file.

ConditionalPutCommitHandler writes _versions/{version}.manifest and _versions/d{version}.manifest with PutMode::Create. Local, file, S3, GCS, and Azure use this handler. It reports a missing version as definitive. After CommitError::OtherError, verify_commit_outcome returns Absent on the first 404. cleanup_transaction_file then deletes _transactions/{read_version}-{uuid}.txn. The put can land after that 404. The manifest then names a missing transaction file. Lance says callers may delete that file only when the commit did not land (lance 11.0.0 src/io/commit.rs).

key_class marks that put Immutable. The test lost_ack_on_an_immutable_key_fails_without_poison_issue_1067 requires the scope to stay settled for a detached manifest and for __manifest version 18446744073709551602.manifest. The delete then runs. On ff9519fe the same lost acknowledgement poisons the scope, and the scope refuses the delete. That refusal is the warning in #1067.

This pull request says a read-back cannot exclude a late landing of an unsettled __manifest put. The same window is open here. For a linear __manifest commit, Lance deletes the transaction file and then returns the error. publish_with_precondition can read that version and return success when the head matches the attempt (omnigraph-catalog publisher.rs). The published catalog version then has no transaction file. A transaction of at most 20 MiB stays readable from transaction_section. Dataset::read_transaction prefers that section. The collector still lists _transactions/{transaction_file} (collector.rs). A larger transaction has no inline copy, and read_transaction fails. A detached table manifest takes the same delete (do_commit_detached_transaction). The incident was that path. The writer does not publish it. The late file is still a version whose transaction file is gone.

Keep the scope uncertain for a manifest put until Lance can no longer delete the transaction file. A late manifest is not unreclaimed garbage after that delete.

  1. A lost acknowledgement on multipart complete leaves the scope uncertain.

ScopedUpload::complete settles the upload lifetime only when complete returns Ok. The child request settles, because a data/*.lance key is immutable. The lifetime drop sets uncertain. Lance uses one PUT while the object fits in the buffer. The default buffer is 5 MiB (INITIAL_UPLOAD_STEP in lance-io object_writer.rs). A larger data file uses multipart complete. The writer can return Unpublished, so the HTTP error stays definite. The scope stays uncertain. Later mutations fail. A clean SIGTERM sees scope.is_uncertain() and keeps the cluster lock (omnigraph-server src/lib.rs). The changelog says a clean shutdown still releases the lock. The boot test plants a file where data/ should be. It does not complete a multipart upload. The scope test loses a part acknowledgement and then aborts with success. It does not lose the complete acknowledgement.

I added a temporary test on this head. A lost complete of g/node_Person/data/3f2c4d9e.lance left the scope uncertain. The assertion failed. I removed the test. Please add that case. Settle the lifetime when the key is immutable and complete fails.

Optional

_bt/ objects are immutable in the Lance layout (_bt/root/{uuid}.root, _bt/node/{uuid}.node, _bt/leaf/{uuid}.lance). This classifier treats them as mutable. A lost acknowledgement there poisons the scope. That failure stays contained. OmniGraph on Lance 11.0.0 does not write those objects. A later Lance that writes them returns this outage for those keys.

The current Lance blob guide stores managed payloads at _blobs/<uuid>.blob. Pinned Lance 11.0.0 writes data/<data_file_key>/<32-bit name>.blob (blob_path). _blobs/ stays mutable. That failure stays contained.

Schema apply, optimize (stage_compaction and commit_staged_detached before commit_updates_on_branch_with_expected), and CSV overwrite staging in fragment_sink do not attach Unpublished. Those errors carry no evidence. The server contains the process. This pull request states that limit for maintenance and schema. The fragment sink is a load path.

The index rule matches a path segment of length 36. It does not parse a UUID. Another spelling stays contained.

Every immutable failure logs warn!. An expected not-found delete logs there too.

Tradeoff

The change removes one outage. A definite failure of a small single-put write-once object, before publication, no longer stops the server or holds the lock. It adds two liabilities. The scope no longer stops Lance from deleting a transaction file after an unacknowledged manifest put. A multipart complete failure leaves the scope uncertain while the HTTP error says the failure is definite. Each writer also attaches the evidence itself. The next writer must repeat that call. Five more changes of this kind add more key spellings and more writer calls. The cleanup rule and the multipart lifetime stay outside the classifier. Open pull request #1017 is the serving RFC. It should consume this one completion fact. A second outcome type would split the contract.

Validation

GQT cannot express scope poison, completion evidence, or process exit. Rows, counts, and query errors do not show them. The owner is the storage scope test.

Before, commit ff9519fe2cdf7b8389a360ca7b43c54213711ca0. I added a temporary test lost_ack_on_a_write_once_key_must_not_poison_issue_1067 and ran cargo test -p omnigraph-storage --lib --locked lost_ack_on_a_write_once_key_must_not_poison_issue_1067. Result: FAILED. The panic text was a lost acknowledgement on g/node_Person/data/x.lance must not poison the scope. Mutation::finish on that commit settles only success, or AlreadyExists / Precondition on a conditional request. I restored the file. I did not push it.

After, commit 9062bd839a96935f8bc98eef8864a1e05bd1e6bc. cargo test -p omnigraph-storage --lib --locked issue_1067 passed key_class_follows_lance_table_layout_issue_1067 and lost_ack_on_an_immutable_key_fails_without_poison_issue_1067. cargo test -p omnigraph-core --lib --locked unpublished_evidence_is_definite_and_never_hides_uncertainty_issue_1067 passed. The multipart probe above failed on this same head. I restored that edit.

I did not run the boot journey, the server integration test, or a Tigris lost-acknowledgement drill. I did not use CI as proof. I read Lance 11.0.0 commit.rs, object_writer.rs, and blob_path, plus the Lance layout, transaction, object-store, blob, fragment-tree, and index pages.

Inline comment on crates/omnigraph-storage/src/scoped.rs line 193: A put-if-absent numbered manifest is not safe to settle. Lance treats a definitive 404 as Absent and then deletes the transaction file. The put can land after that 404. The manifest then names a missing transaction file. The new scope test requires this Create, and the detached manifest Create, to leave the scope settled. That is the delete window from #1067, with the refusal removed. Keep the scope uncertain until Lance can no longer delete the transaction file.

Inline comment on crates/omnigraph-storage/src/scoped.rs line 470: This child request settles because the data key is immutable. The upload lifetime settles only when complete returns Ok. A lost acknowledgement on complete still drops that lifetime as uncertain. Lance uses this path once the object exceeds the 5 MiB buffer. Settle the lifetime on this error when the key is immutable, and add a lost-complete test. A temporary test of g/node_Person/data/3f2c4d9e.lance failed on this head for that reason.

…acknowledged write

After a manifest put the store did not acknowledge, Lance reads the version
back once, takes one "not found" as definitive and deletes the attempt's
transaction file; a manifest landing later would name a missing file. The
scope now refuses every delete under that dataset's `_transactions/` for its
own lifetime, a settled refusal Lance logs and moves past, and the collector
reclaims the orphan from another process. A conditional conflict holds
nothing.

An abandoned or cancelled request on an immutable key settles instead of
poisoning, which covers a multipart upload's lifetime: a lost `complete`, a
lost `abort` or a dropped upload of a data file leaves the scope settled.
A delete answered "not found" is definite on any key. `_bt/` tree objects
and `_blobs/` payloads are immutable, and the index rule parses a UUID.

The table store marks an opaque error of a staged write or a detached
commit `Unpublished` at the source, so a writer that stages through it gets
the mark without a call of its own.
@ragnorc

ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 4b4761d6 (fix(storage): hold transaction files and settle multipart after an unacknowledged write); the description is updated to match.

Both blocking findings are right, and both are now handled inside the scope rather than by poisoning it. Pushed as a second commit on this branch.

1. Transaction file after an unacknowledged manifest put. The scope now records, for a put of <dataset>/_versions/*.manifest that the store did not acknowledge (any error that is not a conditional conflict, and a cancelled request), that dataset's _transactions/ directory, and refuses every delete under it for the rest of the scope's lifetime. The refusal is a settled error: Lance's cleanup_transaction_file logs it and returns the commit's own error, the manifest put itself stays settled, and a manifest landing late names a file that is still there. The collector reclaims the orphan from another process. A conditional conflict (AlreadyExists / Precondition) holds nothing, since the put definitely did not apply. Owner: scoped::tests::unacknowledged_manifest_put_holds_the_transaction_file_issue_1067 (detached and numbered manifests, the held delete, another dataset's file untouched, the conflict case, clean release still holds).

2. Multipart complete. An abandoned or cancelled request on an immutable key now settles in Mutation::drop instead of poisoning, which is what the upload lifetime is: a lost complete, a lost abort or a dropped upload to data/*.lance leaves the scope settled, and the object either exists whole under its fresh name or never does. The same losses on a mutable key keep poisoning (multipart_lifetime_covers_deferred_abort_and_preexisting_parts, failed_part_can_abort_but_cannot_publish_after_uncertainty unchanged). Owner: multipart_upload_to_an_immutable_key_settles_on_every_outcome_issue_1067 (lost complete, lost abort, abandoned upload, cancelled put).

Optional points.

  • _bt/root/*.root, _bt/node/*.node, _bt/leaf/*.lance and _blobs/*.blob are immutable now; the index rule parses a hyphenated 8-4-4-4-12 hex UUID instead of a length.
  • A delete answered "not found" is a definite outcome on any key and is no longer logged or poisoned (not_found_delete_is_definite_on_a_mutable_key_issue_1067, through the local adapter).
  • The per-writer attachment is now backed by the table store: every execute_uncommitted staging site and commit_detached mark an opaque error Unpublished at the source, so a writer that stages through the table store gets the mark without a call of its own. That covers the CSV fragment sink, and the table-level steps of schema apply and optimize; their other steps still carry nothing, as the description says.
  • On rfc: graph access and cross-process writes #1017: agreed that it should consume this one fact. BeforeEffect and Unpublished are the two definite shapes an outcome model needs to map to "effects: none"; nothing here adds a second outcome type.

Not changed: the __manifest publish itself. An unacknowledged linear manifest put still ends in doubt at the publisher and still contains, since nothing re-issues the conditional request; the transaction-file hold above means a late landing there is at least complete. That is the retries step on the issue.

ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: approve.

This change lets the server stay in service after a storage failure that happens before publication. The failure is definite. The graph did not move.

Head 4b4761d6 closes the two blocking findings from the review of 9062bd83.

What this commit does

After a put of <dataset>/_versions/*.manifest that the store does not acknowledge, the scope records that dataset's _transactions/ directory. It refuses every delete under that directory for the rest of the process. The refusal is a settled error. The manifest put itself stays settled.

An abandoned or cancelled request on an immutable key settles in Mutation::drop. A lost complete, a lost abort, or a dropped upload to data/*.lance leaves the scope settled.

Earlier findings

Blocking 1. A lost manifest acknowledgement deletes the transaction file. Fixed.

ConditionalPutCommitHandler writes the manifest with one put_opts (PutMode::Create). Linux file, S3, and Azure use this handler (lance-table 11.0.0 commit_handler_from_url). is_version_not_found_definitive is true. verify_commit_outcome returns Absent on the first 404. cleanup_transaction_file then deletes _transactions/{file}. Lance says callers may do that only when the commit did not land (lance 11.0.0 src/io/commit.rs).

Mutation::finish takes the guard by value. Drop records the directory before put_opts returns to Lance. The cleanup delete then hits begin. The scope returns a settled error. Lance logs it and returns the commit error. The file stays. A late manifest names a file that is still there.

A conditional conflict (AlreadyExists or Precondition) clears the hold. That put did not apply.

The owner test is scoped::tests::unacknowledged_manifest_put_holds_the_transaction_file_issue_1067. It covers a detached manifest, an overwrite, another dataset, a conflict, and clean release.

Blocking 2. A lost multipart complete poisons the scope. Fixed.

ScopedUpload::complete still settles the upload lifetime only on Ok. On error the lifetime stays. Drop for an unsettled immutable key does not set uncertain. The object exists whole under its fresh name, or it never does.

Lance uses multipart above 5 MiB (INITIAL_UPLOAD_STEP in lance-io object_writer.rs). A manifest on ConditionalPutCommitHandler is one put_opts. It does not use this path.

The owner test is multipart_upload_to_an_immutable_key_settles_on_every_outcome_issue_1067. It covers a lost complete, a lost abort, an abandoned upload, and a cancelled put. The same losses on a mutable key still poison.

Optional findings from the previous review

_bt/root/*.root, _bt/node/*.node, and _bt/leaf/*.lance are immutable. Fixed. Lance 11.0.0 does not write _bt/. The paths match the current fragment-tree layout.

_blobs/*.blob and Lance 11 data/<key>/*.blob are immutable. Fixed.

The index rule parses a hyphenated 8-4-4-4-12 hex UUID. Fixed.

A delete answered "not found" settles on any key and is not logged. Fixed. not_found_delete_is_definite_on_a_mutable_key_issue_1067 uses the local adapter.

Unpublished on schema apply, optimize, and CSV overwrite is partly fixed. commit_detached and the execute_uncommitted staging sites in table_store mark an opaque error. CSV overwrite goes through stage_overwrite_batches. stage_compaction's task.execute, stage_index_fold, stage_full_text_declarations, stage_create_indices, and stage_keyed_write_from_stream still return a bare storage error. The description says those other steps carry nothing. The server still contains those errors. See optional finding 1.

Optional

  1. Some staging steps still carry no Unpublished evidence. stage_compaction maps task.execute with OmniError::storage (table_store.rs). An immutable data-file failure settles in the scope. The engine error has no Unpublished mark. ApiError::from_omni then contains the process. The same gap remains on stage_index_fold, stage_full_text_declarations, stage_create_indices, and stage_keyed_write_from_stream. This is the limit the pull request states. It does not reopen the bug: one storage write with an unknown outcome stops the server until a manual unlock #1067 path.

  2. The hold is attached only to op == "put". See the inline comment. OmniGraph serves Linux file, S3, and Azure. Those stores use ConditionalPutCommitHandler, and the hold covers them. Windows local RenameCommitHandler is outside the hold. Local create-rename is copy then delete in the same process (object_store 0.13.2 local.rs). That is not the lost-response window of a remote PUT. I do not block on it.

  3. A same-process cleanup cannot delete a held transaction file, and the error fails the run. See the inline comment. The file stays. Another process reclaims an unreferenced file after 7 days (UNVERIFIED_THRESHOLD_DAYS). That window is the same window Lance uses for files that still await a manifest. A landing of seconds or minutes stays inside it.

The version hint (_versions/latest_version_hint.json) stays mutable. Lance writes it only for a linear commit on the local filesystem and on S3 Express, and it swallows a put error. A lost acknowledgement there still poisons the scope. Detached commits skip the hint. control-plane.md already states this. This commit does not change it.

Class of cases

OmniGraph accepts local, S3, and Azure URIs. Unknown schemes are refused. GCS, UnsafeCommitHandler, and s3+ddb are not server paths.

ExternalManifestCommitHandler does not treat not-found as definitive. A 404 becomes Unknown after retries. Unknown does not call cleanup_transaction_file. The hold is not required there.

cleanup_transaction_file is the delete under _transactions/ after an unacknowledged manifest write. Data-fragment cleanup before commit deletes files that no manifest names. A late landing there is an orphan. The scope does not hold those deletes. That is a different case.

The hold lasts until the process exits. A long-running server that sees one lost manifest acknowledgement refuses every _transactions/ delete for that dataset until restart. Lance's own cleanup logs the refusal and continues. The collector in another process, or in this process after restart, deletes an unreferenced transaction file only when it is older than 7 days. A referenced file is kept. That is safe.

Tradeoff

The change removes two liabilities. A definite failure of a small write-once object, before publication, no longer stops the server or holds the cluster lock. A late manifest no longer names a missing transaction file.

It adds one Lance-shaped rule in the scope. The scope assumes that Lance deletes the transaction file only after the manifest put returns, and only after a definitive not-found. A future Lance that deletes a different path, deletes before the put returns, or stops treating not-found as definitive would make this hold wrong. The rule sits in the layer that admits deletes. That is the right layer. Five more changes of this kind would add more path spellings and more hold rules beside KeyClass. Keep new spellings in key_class, and keep this one hold.

BeforeEffect and Unpublished both mean effects none. This pull request does not add a second outcome type. Open pull request #1017 can consume this one fact.

An unacknowledged linear __manifest put still ends in doubt at the publisher. The hold makes a late landing there complete. Nothing re-issues the conditional request.

main is be031fea. The merge base with this pull request is still ff9519fe. The two commits on main add a full-text index refusal. They touch error.rs, the server, and table_ops.rs. Those hunks do not overlap this delta. FullTextIndexRequired stays a typed refusal.

Validation

GQT cannot express scope poison, completion evidence, or process exit. Rows, counts, and query errors do not show them. The owner is the storage scope test. I did not add a GQT case.

Before, commit 9062bd839a96935f8bc98eef8864a1e05bd1e6bc. I added two temporary tests in a disposable worktree and ran:

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-storage --lib --locked issue_1067 -- --test-threads=8

Result: FAILED. unacknowledged_manifest_put_holds_the_transaction_file_issue_1067 panicked with the scope must refuse the transaction-file delete, got Ok(()). multipart_complete_lost_ack_on_immutable_key_issue_1067 panicked with a lost complete on an immutable key must settle. Two existing tests passed. I removed the temporary tests. I did not push them.

After, commit 4b4761d6c93f490ab9d37dcedaec8e0ab59b0ad5. The same command, after cargo clean -p omnigraph-storage --locked, compiled omnigraph-storage from that commit. Result: 5 passed, including the two owner tests on the head. cargo test -p omnigraph-storage --lib --locked scoped::tests -- --test-threads=8 passed 15 tests.

The scope test calls store.delete directly. It does not run Lance verify_commit_outcome. The order is from finish and Drop. The hold is recorded before the put returns to Lance.

I did not run the boot journey, the server integration test, or a live lost-acknowledgement drill.

CI on this exact head passed, including Test Workspace, Clippy, Format, RustFS S3, Azurite, and GQT ordinary. That is CI evidence. It is not the before/after proof above.

Inline comments

crates/omnigraph-storage/src/scoped.rs line 227:

Optional. The hold starts only when op is put and the object name ends in .manifest.

RenameCommitHandler is the Windows local handler (lance-table 11.0.0 commit_handler_from_url). It writes a staging object whose name does not end in .manifest, then calls rename_if_not_exists. ScopedObjectStore::rename_opts records that request as rename. transactions_dir stays empty. A lost acknowledgement on that rename does not hold the transaction files. is_version_not_found_definitive is still true, so a definitive 404 still runs cleanup_transaction_file.

OmniGraph serves Linux file, S3, and Azure. Those stores use ConditionalPutCommitHandler and one put_opts. The hold covers them. S3 and Azure on Windows use that same handler.

Local RenameTargetMode::Create in object_store 0.13.2 is copy_opts then delete in the same process (local.rs). That is not the lost-response window of a remote PUT. I do not block on this residual.

crates/omnigraph-storage/src/scoped.rs line 143:

Optional. This refusal is a settled Generic error. collector::delete_object treats only NotFound as already gone. Any other error fails the cleanup run.

delete_paths deletes swept data files and manifests before it deletes orphan transaction files. A held _transactions/ directory can fail that run after those earlier deletes. The transaction file stays.

The hold is the whole directory, for the life of the process. The same process cannot reclaim any transaction file of that dataset until it exits. Another process can. plan_orphans deletes an unreferenced _transactions/*.txn only when last_modified is older than UNVERIFIED_THRESHOLD_DAYS (7). A landing of seconds or minutes stays inside that window.

This is safe. It is broader than the one file a late manifest names.

ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: approve.

This change lets the server stay in service after a storage failure that happens before publication. The failure is definite. The graph did not move.

Head 54ef8cf0 keeps the two blocking fixes from 4b4761d6. A manifest write the store does not acknowledge still retains transaction files. A multipart upload of a fresh immutable key still settles on every outcome.

What this delta does

33587a91 merges the previous head into current main (be031fea). That merge adds no settlement rule. The author's changes are 217fb403 and 54ef8cf0.

217fb403 narrows the keys that settle, and widens the transaction-file hold.

A request settles only for Lance 11 fresh files the classifier names: data/*.lance, data/<file-key>/*.blob, _transactions/*.txn, _deletions/*.arrow|*.bin, a flat file in _indices/<uuid>/, a detached _versions/d<digits>.manifest, and a numbered manifest on a create-only request. An if-match update is mutable on every key. A recursive delete is mutable. A rename accounts for its source. An unacknowledged manifest put, copy, rename, or multipart completion sets one sticky flag. That flag refuses every transaction-file delete in the scope, and every local prefix delete. A definite conflict sets nothing. A later successful abort does not clear a hold that an earlier completion already set.

54ef8cf0 changes one sentence in the testing guide. The owned_shutdown journey owns a proven unpublished staging failure whose storage I/O has settled. The test still plants a file where data/ must be a directory. That fault is one such failure. The sentence matches that journey. It does not claim the scope matrix.

Earlier findings

Blocking findings from the review of 4b4761d6. Both still hold.

  1. A lost manifest acknowledgement must not delete the transaction file. Still fixed. ConditionalPutCommitHandler writes the manifest with one put_opts (PutMode::Create). Linux file, S3, and Azure use this handler (lance-table 11.0.0 commit_handler_from_url). is_version_not_found_definitive is true. verify_commit_outcome returns Absent on the first 404. cleanup_transaction_file then deletes _transactions/{file}. Lance says callers may do that only when the commit did not land (lance 11.0.0 src/io/commit.rs). Drop sets the hold before put_opts returns to Lance. The cleanup delete hits begin. The scope returns a settled error. The file stays.

  2. A lost multipart complete must not poison the scope. Still fixed. ScopedUpload::complete settles the lifetime only on Ok. On error the lifetime stays. Drop for an unsettled immutable key does not set uncertain. Lance uses multipart above 5 MiB (INITIAL_UPLOAD_STEP in lance-io object_writer.rs). A manifest on ConditionalPutCommitHandler is one put_opts.

Optional findings from that review

  1. Some staging steps still carry no Unpublished evidence. Still open. stage_compaction maps task.execute with OmniError::storage. stage_index_fold, stage_full_text_declarations, stage_create_indices, and stage_keyed_write_from_stream do the same. Mutation, load, Blob, and merge still mark the steps they own. A direct optimize or schema-preparation error has no mark. The server contains that error. The new text in writes.md and control-plane.md states this limit. It does not reopen the bug: one storage write with an unknown outcome stops the server until a manual unlock #1067 path.

  2. The hold started only on op == "put". Fixed for the manifest write. Copy, conditional rename, and multipart completion of _versions/*.manifest now set the same hold. A definite AlreadyExists or Precondition sets nothing. See the inline note for the Windows staging-file delete that remains.

  3. The same process cannot delete a held transaction file, and that error fails the cleanup run. Still open, and now broader. One flag covers every dataset, not one _transactions/ directory. It also refuses every local delete_prefix. The file stays. Another process can reclaim an unreferenced file after 7 days (UNVERIFIED_THRESHOLD_DAYS). See the inline note for an S3 or Azure prefix delete in this same process.

_bt/ and flat _blobs/*.blob were immutable at 4b4761d6. They are mutable again. The UUID rule is unchanged. A delete that answers "not found" still settles on any key.

Optional

  1. Lance 11 full-text staging files sit outside the settled set. The classifier marks a flat file in _indices/<uuid>/ immutable. It marks a nested file mutable. Lance 11 writes full-text staging objects at _indices/<uuid>/staging/... (STAGED_PARTITION_DIR in lance-index 11.0.0 scalar/inverted/builder.rs). A lost acknowledgement on that fresh name poisons the scope. Final partition files (part_<id>_*) stay flat and still settle. _bt/ is not a Lance 11.0.0 write. Managed blobs use data/<file-key>/<32-bit name>.blob (lance-core blob_path). This is the safe direction: an unknown layout stays contained. It is also a return of the outage the previous head had closed for _bt/ and _blobs/.

  2. One hold blocks every dataset, and an S3 or Azure prefix delete is not one request. Local delete_prefix calls begin once and runs remove_dir_all only after that check, so a hold refuses the whole prefix. S3 and Azure list keys and delete them one by one (omnigraph-storage delete_prefix). A transaction file is refused. Keys listed before it are already gone. Object-store list order is lexicographic, so _deletions/ and _indices/ precede _transactions/. A same-process graph-root deletion can remove those objects, then stop, and it cannot finish until a new scope. The error stays an unknown deployment outcome. It is not a successful delete. The collector still deletes only paths it has already judged unreferenced.

  3. A Windows local manifest commit can poison the scope after Lance has proved the commit landed. RenameCommitHandler writes {manifest-path}-{uuid} and then rename_if_not_exists (lance-table 11.0.0 make_staging_manifest_path). The scoped create-rename copies to the manifest path and then deletes the source. The copy acknowledgement clears this request's hold. The staging name does not end in .manifest, so the source delete is mutable. A lost acknowledgement on that delete sets uncertain, and Lance then reads the manifest back as its own commit (propagate_commit_error_after_success is false). Linux file, S3, and Azure use ConditionalPutCommitHandler and one put_opts. I do not block on this residual.

Tradeoff

The change removes one misclassification. An if-match update of a numbered manifest can replace bytes. 4b4761d6 treated that request as immutable because the mode is conditional. A lost acknowledgement settled. The new code forces PutMode::Update to mutable. That is the costly direction, corrected: an unknown replacement must not look definite.

The change adds a broader hold and a shorter immutable list. The hold is one flag, not a path list. Five more changes of this kind belong in key_class, beside this one flag. A future Lance that writes a new fresh-file shape will contain the process until key_class names it. That is the right failure. A future Lance that deletes the transaction file on a different path, or before the manifest write returns, would make this hold miss. The rule still sits in the layer that admits the delete.

BeforeEffect and Unpublished still mean effects none. This pull request does not add a second outcome type. The catalogue still owns an unacknowledged numbered-manifest publication. Scope settlement does not make that publication definite.

main is be031fea. This head contains it through 33587a91. The pull request record still names base ff9519fe. The merge is clean. The two commits on main add a full-text index refusal and a ranked-binding root. They do not change this settlement rule. FullTextIndexRequired stays a typed refusal.

Validation

GQT cannot express scope poison, completion evidence, or process exit. Rows, counts, and query errors do not show them. The owner is the storage scope test. I did not add a GQT case.

Before, commit 4b4761d6c93f490ab9d37dcedaec8e0ab59b0ad5. git diff 4b4761d6 33587a91 -- crates/omnigraph-storage/src/scoped.rs is empty, so this is the classifier 217fb403 replaced. I added four temporary tests in a disposable worktree and ran:

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-storage --lib --locked -- delta -- --test-threads=8 --nocapture

Result: FAILED. 0 passed, 4 failed.

  • if_match_numbered_manifest_lost_ack_must_poison_delta panicked with if-match can replace an existing publication key. The scope stayed settled.
  • manifest_copy_lost_ack_must_hold_transactions_delta panicked with called Result::unwrap_err() on an Ok value. The transaction-file delete ran.
  • manifest_complete_then_abort_must_hold_transactions_delta failed the same way. A successful abort left no hold.
  • delete_prefix_must_not_inherit_file_class_delta panicked with a recursive removal can also delete mutable descendants. A prefix named data/x.lance did not poison.

I removed the temporary tests. I did not push them.

After, commit 54ef8cf0f37d8424acf4848d1f72f85556ec6320. I ran cargo clean -p omnigraph-storage --locked and then:

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-storage --lib --locked scoped::tests -- --test-threads=8

Result: 23 passed, 0 failed.

The scope tests call the wrapper. They do not run Lance verify_commit_outcome. The hold is recorded in Drop before the store call returns to Lance. I read the pinned Lance 11.0.0 handlers, verify_commit_outcome, cleanup_transaction_file, and the multipart threshold in the crates.io sources. I did not run the boot journey or a live lost-acknowledgement drill. CI on this exact head passed, including Test Workspace, Clippy, Format, RustFS S3, Azurite, and GQT ordinary (run 39136034499). That is CI evidence. It is not the before/after proof above.

Inline comments

crates/omnigraph-storage/src/scoped.rs line 258:

Optional. A nested index path is mutable. Lance 11 writes full-text staging objects at _indices/<uuid>/staging/... (STAGED_PARTITION_DIR in lance-index 11.0.0 scalar/inverted/builder.rs). A lost acknowledgement on that fresh name poisons the scope. Flat part_<id>_* files still settle. _bt/ and flat _blobs/*.blob were immutable at 4b4761d6 and are mutable again. Lance 11.0.0 does not write _bt/. Managed blobs use data/<file-key>/<name>.blob. This is the safe direction. It returns the outage the previous head had closed for those two layouts.

crates/omnigraph-storage/src/scoped.rs line 217:

Optional. This one flag refuses every transaction-file delete and every local prefix delete. Local delete_prefix checks the flag before remove_dir_all, so the tree stays. S3 and Azure list keys and delete them one by one. A transaction file is refused. Keys listed before it are already gone. Lexicographic order puts _deletions/ and _indices/ before _transactions/. A same-process graph-root deletion can remove those objects, then stop, and it cannot finish until a new scope. The error stays an unknown deployment outcome. The collector still deletes only paths it has judged unreferenced.

crates/omnigraph-storage/src/scoped.rs line 541:

Optional. An acknowledged copy clears this request's hold before the source delete. RenameCommitHandler is the Windows local handler. It writes {manifest-path}-{uuid} and then renames that object onto the manifest (lance-table 11.0.0). The staging name does not end in .manifest, so the source delete is mutable. A lost acknowledgement there poisons the scope after the manifest copy has already succeeded. Lance then reads that manifest back as its own commit. Linux file, S3, and Azure use ConditionalPutCommitHandler and one put_opts. I do not block on this residual.

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