Skip to content

rfc: remote optimize through the serving owner - #1214

Open
ragnorc wants to merge 9 commits into
ModernRelay:mainfrom
ragnorc:rfc/remote-optimize-v3
Open

ragnorc wants to merge 9 commits into
ModernRelay:mainfrom
ragnorc:rfc/remote-optimize-v3

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Reopens the remote-optimize RFC for review. It merged early as #1190 (still status: draft) and #1203 reverted it; this is the same text plus the fixes its post-merge review asked for, recorded in the decision log under 2026-10-29 "Reopened":

  • "takes docs(rfc): optimize graphs through the running server #1186's ceilings" became "adapts": docs(rfc): optimize graphs through the running server #1186 refuses a whole request over a ceiling, this RFC defers per table and resumes; every difference from docs(rfc): optimize graphs through the running server #1186 is now listed and its narrow-grant argument is answered;
  • the record's 32 / 1 MiB / 4 MiB bounds share the deployment receipts' constants instead of restating them;
  • the resolution walk is bounded per attempt (resolution_walk_limit), resumed below the stop commit and reported by lookup;
  • the only exit from a full unresolved list is a move of main's graph head; no override, and a fence commit is named as a later slice;
  • why the before_publish callback was preferred over a two-phase engine API;
  • containment today covers DataFusion and RecoveryRequired only;
  • the artifact write is skipped under a pending shutdown, and its default limit is sized against the shutdown grace at qualification.

Trimmed after comparing with LanceDB's served maintenance (its embedded optimize() is a client-side call, its remote tables refuse it, and its managed service runs compaction off the request path as registry jobs with durable ids, cancel and reattach):

  • the HTTP request carries tables and max_gate_hold only; resource ceilings and the run budget are serve settings alone, as docs(rfc): optimize graphs through the running server #1186 had it;
  • the record is keyed by kind and holds no event history, progress, pause or resume; a job registry is the control plane's;
  • the out-of-process worker is deferred, not rejected: Lance's compaction task API (plan_compaction, serializable CompactionTask, execute, then the commit) already separates the steps TableStore uses, so a later slice moves execute alone while the owner keeps the plan and the one publication.

A new §3.6 states every cross-process hazard once, in one table: the cluster lock, the S3 lease, the manifest version CAS, the record's conditional puts, detached staging, pinned reads, the in-process gates, standalone roots, raw writers, the other maintenance operations and a later worker. No mechanism is added.

A bounded, manually requested optimize of a served graph's main branch, executed by the process that serves and publishes it: today's gate shape and one publication, a gate-hold bound with an exact outcome per table, a durable operation record under a caller-chosen id before any effect, lookup by that id after a lost response, and restart resolution from the graph's history.

Competing draft: #1186 (a synchronous call with no record). Only one of the two should be accepted; the comparison is in this RFC's Relationship to open work and Alternatives, and on #1186.

Blocked on #1175, #1176 and an in-region measurement, as the front matter says. Implementation drafts: #1204 (engine); the server and CLI slices reopen after acceptance.

Docs-only; python3 scripts/check-docs.py passes.

2026-11-01. Revised after the reopened review and the design spike (both comments on this PR): the resolution walk is CommitGraph::first_parent_page with the page's unvisited next as the cursor; settlement is per graph; a graph's deletion settles its unresolved records as graph_deleted (chosen over refusing the deployment); the registered operation writes the acceptance; the record bounds compose (terminal-only total, 512-byte detail, outcomes summarized past 1,024 tables); authority is captured again under the held gates; an id at or before the eviction mark is refused maintenance_id_expired; §3.6 says when a late manifest put lands; an over-ceiling max_gate_hold is 400. Amendment 5 of the spike (artifact write checked at issue) was already in §1.8 and §3.4 and is an implementation note for #1204.

2026-11-01, second revision. The record is now generic: __cluster/operations.json, operation_id, generic lookup and cancel routes under /graphs/{graph_id}/operations/, and a per-kind start route. New §2.6 states what the record owns (id, acceptance before effect, states, cancel, lookup, retention, eviction mark, nonce and the history walk) and what a kind supplies (start route and typed request, grant, admission scope, settlement branch, typed outcomes, kind-local state, settlement when what it names is gone), with optimize as the first kind and an asynchronous merge as the named next one, not in this RFC. §Alternatives records why a job registry with a JSON spec, events, pause and resume (LanceDB Distributed's /v1/jobs) stays with the control plane.

2026-11-01, third revision. After the review of head 2413b5a, all seven findings applied. The header now matches §2.6: active operations keyed by admission scope, kind state keyed by graph and kind, the settlement branch stored on the record and walked by §2.4, lookup and cancel authorized by the record's kind. Graph deletion settles every non-terminal record as a step of complete_graph_deletion on both passes before Deleted, walking while the root is readable and writing graph_deleted only after verified absence; the lifetime is schema_identity_domain. Each scope has an in-process gate held across the acceptance put. The eviction mark is the evicted id's own ULID time. Both first_parent_page failures keep a record publishing. The server-runtime pending amendment names operations.json, and the authority testing row is the served path.

ragnorc and others added 3 commits October 31, 2026 13:37
* rfc: remote optimize through the serving owner

A bounded, manually requested optimize of a served graph's main branch,
executed by the process that serves and publishes it: today's gate shape
and one publication, a gate-hold bound with an exact outcome per table, a
durable operation record under a caller-chosen id before any effect,
lookup by that id after a lost response, and restart resolution from the
graph's history. Replaces the closed served-maintenance proposal in the
form its review asked for.

* rfc: link the remote optimize RFC to its discussion

* rfc: revise remote optimize after review

Record the intent nonce and resolve by commit_id_answers; keep an
in-doubt publication non-terminal; release the writer gates before the
artifact scan; add a resume position, resource ceilings and per-table
failure isolation; require maintain on every route; depend on ModernRelay#1175
and ModernRelay#1176.

* rfc: settle remote optimize resume, ceilings and resolution after second review

* rfc: settle remote optimize resolution reads and the artifact bound after third review

* rfc: resolve through the buffered commit lookup and count both offset sides after fourth review

* rfc: state the history cost of a released commit and reuse the collector's path resolution after fifth review
Adapt rather than take ModernRelay#1186's ceilings and list every difference; share
the record's bounds with the deployment receipts; cap the resolution walk
per attempt; name the exit from a full unresolved list; justify the
callback over a two-phase engine API; narrow the containment claim; skip
the artifact write under a pending shutdown and size its limit against the
grace at qualification.
…path

The HTTP request carries tables and max_gate_hold only; resource ceilings
and the run budget are serve settings. The record is keyed by kind and holds
no event history, progress, pause or resume. The out-of-process worker is
deferred rather than rejected: Lance's compaction task API already separates
planning, execute and the commit, and a later slice moves execute alone.
A §3.6 table names each hazard, the mechanism that owns it and where it is
decided: the cluster lock, the S3 lease, the manifest version CAS, the
record's conditional puts, detached staging, pinned reads, the in-process
gates, standalone roots, raw writers, the other maintenance operations and
a later worker. No mechanism is added.

ragnorc commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: revise.

Why

A served graph cannot run optimize while the server holds the cluster lock. Small commits then add fragments for as long as that server runs. This RFC gives that work to the process that already serves and publishes the graph. The caller chooses an operation id. The server stores the request before any effect. The engine still makes one graph publication. Lookup answers a lost response. Restart reads the graph and does not run the work again.

What

This pull request is a design document. It adds the RFC and three pending amendments. It changes no executable code.

The current head is ba6e2b4a6bf0dd4a4a3890e801d25a1e438fc54b. That commit is one past 6a5c3d7498b85115650dfc9dbfcc9f722be7a677. The extra commit adds §3.6. I reviewed the current head, including that delta. No review and no comment existed on this pull request before this one.

How

Acceptance is a conditional put of the operation record. The run holds today's writer gates, works one table at a time, and publishes the finished tables once. The record stores the intent nonce before any manifest request. A publishing record stays non-terminal until that graph's main head settles it.

Impact

The serving process is the right owner. The findings on #1190, through the ready review of d1e0fa33 and the follow-up in 19190bb1, are still resolved in this text. I did not repeat them. The reopen log matches that follow-up, and it matches #1186's ceilings as an adaptation. #1203 reviews the revert only. It raises no defect in this RFC.

Two sentences still disagree with the catalog on this merge base. An implementation that follows them builds a second parent-chain walk, and it can leave a full unresolved list stuck after a write on the wrong graph. Fix those sentences before an implementation follows them. Keep #1186 closed. Two accepted optimize RFCs would leave two contracts.

What the earlier discussion still holds

Checked again on merge base 7b3a1c9190730d31391c225a63c7b6171146bc74 and on Lance 13.0.0. These points stay settled.

  • The cluster lock is the boundary. targets.rs accepts a path, file://, s3://, and az://. run_optimize takes the lock through ensure_graph_with_binding. Direct optimize stays for a stopped server. feat(cli): accept an inline s3:// cluster root for deployment, recovery and maintenance #1189 remains complementary.
  • The record stores the nonce. attempt_commit_id still writes hb1.{block}.{slot}.{nonce} from that attempt's parent and buffer. commit_id_answers is the match. One optional callback on commit_updates_on_branch_with_expected, before the first attempt, is before every manifest request.
  • publishing stays non-terminal. Settlement is this graph's main head, not the latest __manifest version. Dataset::update_config can take a version and leave the head in place. cancelled is legal only when no manifest request was sent. Admission resolves an ownerless accepted or running record before the active-slot check.
  • DeletionFile in Lance 13.0.0 has no size field. DataFile::file_size_bytes is a CachedFileSize. The layout page joins data/ for a dataset root and appends a data-only base with no extra directory. dataset_dir_for_deletion refuses a base that is not a dataset root. The collector's probe_directory follows the crate.
  • Stable row ids survive compaction. The row-id page says the logical id stays and the address changes. compact_files states that row ids change when stable row ids are off. stage_compaction still refuses an address-style rewrite.
  • CompactionTask is Serialize and Deserialize. Lance's module docs split plan_compaction, execute, and commit_compaction. The deferred worker that moves execute alone matches that split. TableStore still commits a detached rewrite of its own.
  • Writer gates still cover the configuration clear and the artifact today. The schema permit and the table guards drop first (optimize.rs). clear_manifest_auto_cleanup still returns committed: false and still fails the whole call. The artifact failure is already a warning.
  • ApiError::translated marks DataFusion and RecoveryRequired uncertain when they carry no completion evidence. ApiError::internal also sets that flag. Evidence BeforeEffect or Unpublished clears it. Labeling every optimize failure before the publication is the rule that covers the class.
  • write_text_if_match still returns on the first error. fix(storage): re-issue and read back conditional control-object writes #1176 is still open and is still the right dependency. MAX_RESULTS, MAX_RESULT_BYTES, and MAX_RESULTS_BYTES are still 32, 1 MiB, and 4 MiB. validate_state still requires a canonical ledger id. ClusterState still denies unknown fields.
  • The cited lines still match: graph-access :284, RFC 0011 Decision 11 :536-547, server-runtime :811, and the 13.8× screen at :1246-1252.
  • feat(server): run optimize through the serving owner with a durable record #1205 and feat(cli): run optimize through served maintenance on a server target #1206 are closed until this RFC is accepted. Their branches stay as later rollout steps. feat(engine)!: bound optimize runs and report each table's outcome #1204 stays a draft on feat(policy)!: gate maintenance behind the maintain and reclaim actions #1175. feat(policy)!: gate maintenance behind the maintain and reclaim actions #1175 is still the maintain and optimize_as dependency. admin granting maintain is that draft's contract, not a fact on main.

Blocking

1. Resolution needs the catalog's parent-chain page

§2.4 walks main by calling GraphCoordinator::published_record once per commit, then stores a stop id and resumes below it.

CommitGraph::first_parent_page is already that walk. It landed in #1201, which is in this merge base. It returns up to limit commits, newest first, plus next. next is the parent the page did not visit. The head, the buffer, and the history cache cost no request. A released commit is read with its __history extent, and the rest of that extent is reused. A missing link ends the page. The page does not check out other branches.

published_record is a different lookup. On a held miss it reads __history, then record_in_live_branches, which opens main and checks out every other live branch. A long walk pays that miss once per absent id. Open #1184 inserts a branch hint into that same function. A sentence that freezes today's order will drift when #1184 lands.

The resume sentence has a second defect. "Resumes the walk below that commit" drops the stored commit. That is sound only when the stored commit was already tested and was not the input head's child. A skipped child makes the walk pass the input head. §2.4 then stays in Neither, so the record stays publishing. A full unresolved list then refuses admission. first_parent_page's next cannot skip a commit, because the next page starts at the unvisited parent.

Call first_parent_page on a coordinator that holds a fresh read of this graph's main. Test commit_id_answers on the returned commits. Store next as the resume cursor. A missing link stays publishing. not_committed stays the case where the returned child does not answer the nonce. Keep the graph-head test in front of the page. Later maintenance kinds call the same page. They do not grow a second walker.

2. One write settles the records of that graph only

The unresolved list is cluster-wide, with a cap of four. §2.4 already says that only a commit on this graph's main settles a record. A commit on another graph does not.

The exit paragraph then says that any mutation, load, or merge that publishes on main settles every unresolved record at once. That is true for the records of the graph that moved. It is false for a record whose graph did not move. One cluster can hold doubts for more than one graph. A write on the wrong graph leaves the list full, and admission stays at 409.

Say the graph in the operator instruction. A move of graph G's main head settles the unresolved records whose input head is on G. The same sentence should cover the earlier line that another maintenance publication "on main" settles the doubt. The active slot is cluster-wide. Settlement is per graph.

Optional

1. A late manifest put can still create the absent version

§3.6 says a late manifest put meets a held version and fails as a conflict, so it never overwrites the successor's commit. The put-if-absent in commit.rs (CommitBuilder::with_max_retries(0) on a linear overwrite) does refuse a version object that already exists. It does not refuse the put when that object is still absent. That landing is the in-doubt case §2.4 exists to resolve. Point this row at §2.4, and say that the conflict is the case where the version is already present.

2. 413 is the body-size refusal

§3.1 maps a max_gate_hold above the server ceiling to 413 payload_too_large. http_contract.rs uses that code when the request body exceeds its route limit, before admission. A time ceiling is a bad request. Reuse the bad-request code, and leave 413 for the body.

Tradeoff and liability

Keeping today's gates is the right first cut. The bound makes the writer stall finite. This slice gives up low write latency during a run. That is the right trade. A new gate shape is a second design.

The record is the right place for a refusal, a no-op, a cancellation, a deferral, and the principal. History remains the source for whether the commit exists. The nonce is the lookup key. The graph-head rule still removes a false not_committed when the only new version is a configuration commit.

The lower-bound artifact pre-check and the counting build still match GraphIndex::build and serialize. Both offset sides belong in the count. Dataset::count_rows(None) still calls count_all_rows. That split is worth keeping.

This design removes the need to stop the server. It removes the blind retry from the closed proposal. It adds one control object, four conditional puts, a 32-record cap, one active slot, and an unresolved list of four. Those costs are worth keeping if the walk and the exit rule have one owner each.

The new liability is a resolution procedure that copies a chain the catalog already pages, and an exit rule that treats a cluster-wide list as one graph. Five later kinds (cleanup, repair, full-text rebuild, index build, named-branch optimize) should add a kind and a route. They should pass the same optional callback. They should call first_parent_page. They should settle each record from that record's graph. They should not add a file, a callback, or a walker.

The owner for "recorded before the manifest request" remains the optional argument on commit_updates_on_branch_with_expected. The owner for "the next page of this graph's first-parent chain" is CommitGraph::first_parent_page. The operation record remains the one server object for maintenance kinds. __cluster/state.json stays deny_unknown_fields, so the separate object is right.

Validation

Reviewed head ba6e2b4a6bf0dd4a4a3890e801d25a1e438fc54b. gh pr view 1214 returned that oid. The pull request was open. Reviews, review comments, and issue comments were empty. The requested oid 6a5c3d7498b85115650dfc9dbfcc9f722be7a677 is the parent of this head. git diff 6a5c3d74 ba6e2b4a is §3.6 only, +27 lines.

Merge base with origin/main is 7b3a1c9190730d31391c225a63c7b6171146bc74, which is origin/main. git diff --stat on that range is five documentation files, +1232 / −3. git diff --check on that range is clean. No Rust file changes.

This RFC changes no behavior a .gqt case can show. There is no before/after logic run. A green GQT run would not test the document. The sentences about current code were checked by reading that code and the pinned Lance 13.0.0 crate.

python3 scripts/check-docs.py at this head reported Documentation OK (441 Markdown files checked).

Code and Lance reads at this tree:

  • crates/omnigraph/src/db/omnigraph/optimize.rs (gates, committed: false, artifact call)
  • crates/omnigraph/src/db/omnigraph/table_ops.rs and crates/omnigraph-catalog/src/publisher.rs (attempt_commit_id, ExactGraphHead)
  • crates/omnigraph-catalog/src/commit.rs (zero-retry linear overwrite)
  • crates/omnigraph-catalog/src/commit_graph.rs (first_parent_page)
  • crates/omnigraph/src/db/graph_coordinator.rs (published_record)
  • crates/omnigraph-catalog/src/lib.rs (held_record, record_in_live_branches, HISTORY_RELEASE_BYTES)
  • crates/omnigraph-catalog/src/history.rs (read_selected, settle_closed)
  • crates/omnigraph/src/graph_index/mod.rs and persist.rs
  • crates/omnigraph/src/db/omnigraph/collector.rs (probe_directory, capture_dataset_paths)
  • crates/omnigraph-server/src/lib.rs (ApiError::translated, shutdown grace 25)
  • crates/omnigraph-cluster/src/deployment.rs (32 / 1 MiB / 4 MiB)
  • crates/omnigraph-storage/src/lib.rs (write_text_if_match)
  • Lance 13.0.0 dataset/optimize.rs, dataset.rs (count_rows, data_file_dir_for_base, dataset_dir_for_deletion), lance-table format/fragment.rs, io/deletion.rs
  • Lance layout page and row-id page, fetched 2026-10-31. The layout page matches the crate's data-file join. The row-id page says a stable row id survives compaction and the address does not.

Limitations

Engine tests were not run. They do not exercise this document. The external 576.8 s and 286.8 s runs were not measured here. S3 and Azure admission were not executed. The in-region ceiling measurement is still future work, and the route stays disabled until it exists. I read #1204's summary and #1175's dependency text. I did not review those diffs line by line. #1184 is open and can change published_record before this RFC is implemented.

Inline comments

docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md:639 (RIGHT)

Blocking. This walk already exists.

CommitGraph::first_parent_page (commit_graph.rs) returns a bounded first-parent page, newest first, plus next. next is the parent the page did not visit. The head, the buffer, and the history cache cost no request. A released commit comes back with its __history extent. A missing link ends the page. The page does not check out other branches. #1201 put this on main, and this merge base contains it.

published_record is a single-id lookup. A held miss reads __history, then record_in_live_branches, which opens main and checks out every other live branch. Open #1184 inserts a branch hint into that function, so a sentence that freezes today's order will drift.

Call first_parent_page on a coordinator that holds a fresh read of this graph's main. Test commit_id_answers on the page. Store next as the resume cursor. "Resumes the walk below that commit" drops the stored commit. A skipped child stays in the Neither case, and the record stays publishing. next cannot skip a commit. A missing link stays publishing. not_committed stays the case where the returned child does not answer the nonce.

docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md:711 (RIGHT)

Blocking. This sentence settles every unresolved record from one write.

The list is cluster-wide. The paragraph above says only a commit on this graph's main settles a record. A commit on another graph does not. One write on graph A leaves a doubt on graph B in the list, and a full list keeps admission at 409.

Name the graph. A move of graph G's main head settles the unresolved records whose input head is on G. The same limit applies to the line that says another maintenance publication on main settles the doubt. The active slot is cluster-wide. Settlement is per graph.

docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md:842 (RIGHT)

Optional. A late manifest put fails when that version object already exists. commit.rs writes the next __manifest version with with_max_retries(0), and Lance creates it only when the object is absent. When the object is absent, the late put becomes the commit. That landing is the in-doubt case §2.4 resolves. It does not overwrite a version the successor already wrote. Point this row at §2.4, and say that the conflict is the held-version case.

docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md:754 (RIGHT)

Optional. payload_too_large is the refusal for a request body that exceeds its route limit before admission (http_contract.rs). A max_gate_hold above the server ceiling is a bad request. Leave 413 for the body.

@ragnorc

ragnorc commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Design spike for this RFC: correct with listed amendments.

Branch: https://github.com/ragnorc/omnigraph/tree/spike/remote-optimize-1214. The report is docs/spikes/remote-optimize-1214.md on that branch.

Verdict

Correct with listed amendments. The overall design holds under every
stress test that a single host can run. The serving owner executes the run.
The record is durable before any effect. The record stores the nonce, and
main's history settles a run after a restart. The run publishes once. The
gate hold is bounded. No finding changes this shape. Four findings need a
change to the RFC text before implementation:

  1. Graph deletion strands unresolved records (§2.4, §3.4). After a deletion,
    the only exit the RFC allows can never happen. With four such records,
    the server refuses maintenance on every graph of the cluster, with no
    way out. This is a rule change, not a new mechanism. It is reproduced.
  2. The acceptance window is not atomic (§3.3). A SIGTERM or a client
    disconnect can arrive after the durable acceptance write and before the
    run is registered. The accepted id then never runs. On SIGTERM the
    caller is also told 503, effects: none.
  3. The record's size bounds do not compose (§2.2). Four records at the
    per-record bound exceed the 4 MiB total. A large catalog can also make
    the before-publish write itself too large, and then every run fails.
  4. The bounded resolution walk should use CommitGraph::first_parent_page
    and store its next parent as the cursor. Settlement is per graph. This
    agrees with both blocking points of the 2026-10-31 review on this PR.

Amendments

  1. The exit when a graph cannot move. §2.4 says the only exit from a
    full unresolved list is a move of the graph's main head. That fails for
    a graph that a deployment deletes, or deletes and re-adds under the same
    id: the new graph's history never meets the input head. The simplest
    rule that keeps "no override" is this: a deployment that deletes or
    replaces a graph refuses while the graph has unresolved maintenance
    records, and the operator settles them first with a write on that graph.
    The alternative is that the deletion, under the same lock, settles them
    with a distinct terminal reason (published unknown, graph gone). That
    adds a terminal meaning, so the refusal is preferable. Either way, the
    record should name the graph's root lifetime, not only its id, so a
    re-added graph never judges an old record.
  2. Accept and register as one unit. §3.3 says the operation is
    "registered before it is spawned". The acceptance write itself also has
    to be inside that unit. Register (or spawn) the owned operation first,
    and let it perform the acceptance write. The HTTP handler then only
    awaits its answer, so neither a disconnect nor SIGTERM can separate the
    durable acceptance from execution. Today the server answers 503 effects: none for an id the record already holds. That breaks "the
    server either refuses, and nothing is accepted, or accepts". On object
    storage, a handler dropped during the conditional put also leaves the
    key unsettled, and the process does not know.
  3. Make the record bounds compose.
    • Cap each table outcome's detail (for example at 512 bytes, keeping
      the code and measure).
    • Make the per-record bound × (unresolved cap + active + one terminal)
      fit inside the total, or exclude unresolved and active records from
      the total.
    • State what a catalog too large for one record does. One option is
      per-outcome counts after N tables. Otherwise the before-publish write
      fails every run.
  4. Resolution text. Walk with first_parent_page on a fresh read of
    the graph's main, after the graph-head test. Store the page's next
    (the unvisited parent) as the cursor, not "the commit it stopped at".
    Say that a write on graph G settles only the records whose input head is
    on G (both points of the 2026-10-31 review). Optional: each admission
    and lookup advances every unresolved record by one page. After a write,
    the exit therefore needs ⌈depth / limit⌉ admission attempts. A time
    budget of several pages per attempt, or a 409 that names the walking
    state, would make this visible.
  5. Artifact. Check for a pending shutdown or cancellation at the moment
    the write would be issued, not only before the scan, and abandon the
    scan when either arrives.
  6. Authority capture. A refusal at revalidation must carry
    BeforeEffect (§1.7 says that every failure before the publication is
    labelled; this one was not). For the served path, capture again under
    the held gates when the capture taken before the gates has moved. No
    writer in the process can move main while the gates are held, and
    without the re-capture about one in five runs failed under write load.
  7. (Optional) Refuse a maintenance_id whose ULID time is older than the
    oldest retained record (maintenance_id_expired). A late retry with an
    evicted id would then not run again. The RFC already says that
    unknown never authorizes a replay. This rule makes the server refuse
    that replay as well.
  8. (Optional, agrees with the review) Answer a max_gate_hold above the
    ceiling with 400, not 413.

Per-claim results

Test names are in crates/omnigraph-server/tests/spike_remote_optimize.rs
(S), crates/omnigraph/tests/integration/spike_remote_optimize.rs (E) and
crates/omnigraph-cluster/tests/spike_record_bounds.rs (C).

Mechanism / claim Result Evidence
Request carries tables and max_gate_hold only; ceilings and budget are serve settings (§1.1, §3.1) Holds (implemented as the RFC states; #1205 diverged) spike server change; all S tests run with the trimmed request
max_gate_hold above the ceiling refused Holds, with the review's optional point: the code answers 413, which http_contract.rs uses for body size; 400 fits better existing maintenance_routes::routes_refuse_*
Durable record under a caller-chosen id, written before any effect (§2.1, §2.3) Holds S spike_kill_at_each_step_resolves_from_history: killed before the acceptance write → unknown, graph unchanged; killed after it → interrupted
Record keyed by kind, no event history (§2.2) Holds (MaintenanceKind::Optimize; one file) code
Record size bounds 32 / 1 MiB / 4 MiB (§2.2) Holds with a change (amendment 3) C spike_per_record_and_total_bounds_do_not_compose: 3 unresolved + 1 publishing at 1 MiB each = 4,194,386 bytes > 4,194,304, so the record is unwritable. Once one settles, the record still fails validation. C spike_tables_per_record: 156 B per plain table outcome (6,721 per MiB), 2,213 B with a 2 KiB failure detail (473 per MiB). 657 failed tables make the before-publish write fail validation, so nothing can publish.
Same id + same request → existing operation; 202 while non-terminal, 200 when terminal (§3.1) Holds S spike_concurrent_submissions_and_lost_response: 8 parallel POSTs, same id → 8 × 202, one operation. Existing route test: same id after terminal → 200 with an identical record.
Same id, different request → 409 Holds existing served_optimize_keeps_a_record_a_caller_reads_by_id
Different ids while one is active → 429 (§3.2) Holds S: 8 parallel new ids while one runs → 8 × 429
Lookup by id after a lost response; never resubmit (§2.5, §4) Holds; one window does not (amendment 2) S: client timeout of 1 ms, then lookup → found, the run → succeeded, a retry under the same id → the identical record, one commit. A request dropped before the server reads it → unknown, and a retry is a fresh acceptance (one run).
Disconnection stops waiting, not execution (Observable contract) Blocked as built; holds with a change (amendment 2) S spike_disconnect_between_acceptance_and_registration: the handler is dropped on disconnect. A disconnect after the acceptance write and before registration → the accepted operation ends interrupted and never runs. A disconnect before the write → unknown. Once this probe also failed intermittently; not reproduced in 6 reruns.
Evicted ids answer unknown (§2.5) Holds as written; optional amendment 7 C spike_an_evicted_id_is_admitted_again: after 32 later operations, the same id and request is admitted as New and would run again
Restart resolution: accepted/running → interrupted; publishing by history; never re-executed (§2.4) Holds S kill matrix, 7 points, each followed by a restart: before accept → unknown; after accept, after running, mid-compaction (after_table_effect) → interrupted, head unchanged, rows intact; after the publishing write → publishing, then failed/not_committed once a later run publishes; after publication and before the terminal write → succeeded with published_commit = main's hb1 head. A resubmission of the same id after restart returns the record and runs nothing.
before_publish callback once, before any manifest request; failure publishes nothing (§1.6) Holds existing E tests (optimize_calls_before_publish_once…, optimize_publishes_nothing_when_before_publish_fails); kill at server.after_publishing_write leaves the head unchanged
The record's conditional put fences an old owner (§3.6, "late record put") Holds S spike_old_owner_is_fenced_by_the_record_after_a_takeover: the old owner is paused mid-run; the operator force-unlocks and a successor starts and records interrupted. The old owner resumes, its before-publish write is refused, it publishes nothing and the record stays interrupted.
Single publication; finished tables publish once (§1.6) Holds existing E tests; the kill matrix shows exactly one commit (4 → 5)
Bounded resolution walk with resolution_walk_limit, resume below the stop (§2.4) Holds with a change (amendment 4) E spike_paged_resolution_reaches_the_child_at_every_boundary: the child sits 13 commits deep, partly released to __history. For limit L ∈ {1,2,3,4,5,6,12,13,14,256}, the walk takes exactly ⌈13/L⌉ attempts to reach Committed, or NotCommitted for a foreign nonce. A cursor taken before main grows still resolves. S: with limit 4 and 10 writes, three admission attempts settle all four records.
Settlement only by a move of the record's graph's main head (§2.4) Holds per graph in code; the text must say so (amendment 4) S spike_unresolved_list_fills_and_only_its_own_graph_settles_it: 4 unresolved on knowledge → a fifth admission is refused with 409, on other as well. Writes on other settle nothing. Writes on knowledge settle all four as failed/not_committed.
Unresolved list cap of 4 and its only exit (§2.4) Blocked as written (amendment 1) S spike_deleted_graph_wedges_the_unresolved_list: 4 unresolved on knowledge, then a deployment deletes knowledge. Writes on other follow, and admission on other is still 409 with 4 unresolved. No main head can move, and the RFC rules out an override.
Gate-hold bound with an exact per-table outcome; deferral and resume (§1.2, §1.3) Holds E spike_many_tables_are_covered_by_successive_bounded_runs, 48 tables with a 150 ms hold. Run 1 visits 32 tables (gate hold 212 ms, largest table 94 ms, a concurrent writer waits 240 ms) and its resume position is node:T32. Run 2 starts there, wraps and finishes, and its position clears. Every table is optimized exactly once, and no run visits a table twice. Existing E: optimize_stops_at_the_gate_hold…, ceilings, byte budget.
Writer wait ≤ max_gate_hold + one table + publication + clear (§1.2) Holds at this scale the measurement above. A single large table is bounded only by the ceilings (RFC unresolved question 1).
Writers on main racing an optimize: writes delay, never fail Holds for writes; holds with a change for the run (amendment 6) E spike_writers_race_optimize_authority_capture: 4 writers and 20 runs. All writes succeed. 4 runs are refused (graph_head changed), and before the spike that refusal carried no completion evidence. With a capture again under the held gates, 0 of 20 are refused. S spike_writers_race_served_optimize: all writes succeed; 0–1 of 10 runs are refused; writer p99 is 172–193 ms.
Concurrent direct runs on one standalone root fail closed (§3.6) Holds E spike_two_direct_runs_on_one_standalone_root: one publishes, the other refuses before any effect, and the rows are intact
A direct optimize on a served cluster root → state_lock_held (§3.6) Holds S spike_cli_walking_skeleton (CLI binary)
A foreign-ledger record is refused, never overwritten (§2.1) Holds S spike_foreign_record_is_refused: submit 409, lookup 409, the file is byte-identical
Artifact write skipped under a pending shutdown; an issued write is awaited (§1.8, §3.4) Holds with a change (amendment 5) S spike_artifact_write_is_skipped_under_a_pending_shutdown: SIGTERM after the scan and before the write → succeeded with its commit, artifact skipped/cancelled, the file is unchanged and the process exits 0. The drafts checked cancellation only before the scan.
Shutdown during the record write (§2.3, §3.4) Holds with a change (amendment 2) S spike_shutdown_during_the_acceptance_write: SIGTERM before and after the acceptance write. In both cases the acceptance still lands after shutdown began, registration then fails and the caller gets 503 with effects: none. After restart the id is interrupted. Exit 0 and a clean release.
Cancellation at safe boundaries; publishing is unchanged by a cancellation (§1.9) Holds existing E cancellation tests and route code; not stress-tested further
Served CLI: mint and print the id, wait, --operation, --no-wait, unknown → exit 2 (§4) Holds existing system_local::local_cli_optimize_runs_served_maintenance passes on this branch
maintain enforced; admin does not grant it (§1.1) Holds policy_engine_chassis 27/27

Baseline runs on this branch: engine maintenance:: 65/65, engine
failpoints optimize 11/11, policy_engine_chassis 27/27, cluster
maintenance 9/9, server maintenance_routes 2/2 and its lib tests 2/2,
CLI system_local::local_cli_optimize_runs_served_maintenance 1/1. Spike
suites: S 11/11 (plus the ignored child helper), E 4/4, C 3/3. The workspace gate, Clippy, the DST golden
and forbidden_apis were not run. The spike's std::env read in the
engine would fail forbidden_apis on purpose.

Not verified

  • S3, in region: request latency, the S3 lease fencing and the
    successor after one and a half leases, a late manifest put landing on a
    version that is still absent (the review's optional point), fix(storage): re-issue and read back conditional control-object writes #1176's
    re-issue under S3's 409 answers, the cost of reading __history for
    released commits, the 576.8 s no-op planning figure, and artifact write
    throughput against the 25 s shutdown grace. Every test here ran on local
    file://, where a killed process cannot leave a put in flight.
  • Azure: not tested.
  • Deployment cancellation of a running optimize before its drain:
    checked by reading the code only.
  • Large tables near the ceilings at production sizes, and peak memory.
    Only the drafts' small per-ceiling fixtures ran (they pass).
  • Collection of staging left by interrupted or failed runs. The spike
    relied on the existing detached_commit_matrix and maintenance owners
    and did not re-run them.
  • A publisher attempt in flight at the version after a
    configuration-only commit. The HeadUnchanged rule was checked by
    reading the code; this cannot be staged on a local filesystem.
  • The DST storage-cost golden, the workspace gate and Clippy.

What was built

The spike starts from the three stacked implementation drafts: #1204
(engine), #1205 (server record and routes) and #1206 (CLI). #1176
(control-object re-issue) is merged on top, and so is current main, for
first_parent_page. Together they already form an engine → HTTP → CLI
walking skeleton. The spike changed the parts that differ from the RFC
text, or that the RFC changed after the drafts were written:

Change Where
The HTTP request accepts only maintenance_id, tables and max_gate_hold_ms. The resource ceilings, the run byte budget and resolution_walk_limit (default 256) are serve settings (OMNIGRAPH_MAINTENANCE_*). #1205 still accepted six ceiling fields in the request body. omnigraph-server/src/maintenance.rs, omnigraph-cluster/src/maintenance.rs
A bounded resolution walk, Omnigraph::resolve_publication_page. It tests the graph head first, then reads one first_parent_page of at most limit commits. It returns Walking { next, covered }, which the record stores (resolution_next, resolution_covered), and the next attempt resumes at next. omnigraph/src/db/omnigraph/resolution.rs
Artifact under shutdown. The edge scan is raced against cancellation and abandoned when it fires. The write is skipped if a cancellation or shutdown is pending at the moment it would be issued. Once issued, it is still awaited. The drafts checked only before the scan. optimize.rs, persist_graph_index_artifact
A revalidation refusal is labelled BeforeEffect. In the drafts it carried no evidence. An opt-in with_spike_recapture_under_gates captures authority again under the held gates. optimize.rs
OMNIGRAPH_SPIKE_PAUSE_AT=<point>[:<ms>] pause points: server.{before,after}_accept_write, server.after_running_write, optimize.after_table_effect, server.after_publishing_write, optimize.after_publication, optimize.before_artifact_write, server.before_terminal_write. omnigraph/src/spike.rs and call sites

Stubs and limits.

  • The pause points are a test instrument.
  • Everything else is real: the engine, the production server in a child
    process, the cluster lock and the record. All of it runs on a local
    file:// cluster root.
  • The CLI's served leg is the existing system test from feat(cli): run optimize through served maintenance on a server target #1206. The spike
    adds only the direct-path lock check.
  • MCP is out of scope, as the RFC says.

Running the spike

cargo test -p omnigraph-server --test spike_remote_optimize -- --test-threads=1 --nocapture
cargo test -p omnigraph-engine --features failpoints --test integration spike_remote_optimize:: -- --nocapture
cargo test -p omnigraph-cluster --test spike_record_bounds -- --nocapture

Walk main with the catalog's first-parent page and store its unvisited next as
the cursor; settle unresolved records per graph; let a graph's deletion settle
them as graph_deleted; write the acceptance from the registered operation;
make the record bounds compose; capture authority again under the held gates;
refuse an id at or before the eviction mark; state when a late manifest put
lands; answer an over-ceiling max_gate_hold with 400.
Name the control object operations.json and the id operation_id; make lookup
and cancel generic routes under /operations/ while each kind keeps its start
route; add §2.6 stating what the record owns and what a kind supplies, with
optimize as the first kind and an asynchronous merge as the named next one;
record why a job registry with a JSON spec, events and pause stays with the
control plane.

ragnorc commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: revise.

Why

A served graph cannot run optimize while the server holds the cluster lock. This RFC gives that work to the process that already serves and publishes the graph. The caller chooses an operation id. The server stores the request before any effect. The engine still makes one graph publication. Lookup answers a lost response. Restart reads the graph and does not run the work again.

What

This pull request is a design document. The reviewed range adds no executable code.

The current head is 2413b5a861f458ea4904491507d4d3ec24a41ca8. The previous review was at ba6e2b4a6bf0dd4a4a3890e801d25a1e438fc54b. Two commits sit between them, both in docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md. The first answers that review and the design spike. The second makes the record a generic operation record. I reviewed that delta. I did not repeat findings the new text already closes.

Prior findings

  1. Resolution walk. Resolved. §2.4 now calls CommitGraph::first_parent_page on a fresh read of this graph's main, after the graph-head test. The cursor is the page's next, the parent the page did not visit. A missing link stays publishing. not_committed stays the case where the page holds the input head's child and that child does not answer the nonce. The walk does not call published_record, so open perf(catalog): read a branch-held commit from the branch that held its block #1184 does not drift it.

  2. One write settles one graph. Resolved. A move of graph G's main head settles the unresolved records whose input head is on G. A write on another graph leaves the list full. The list stays cluster-wide. The new deletion exit is a separate rule, reviewed below.

  3. Late manifest put. Resolved. §3.6 now says a put that meets a version the successor already wrote conflicts, and a put that finds the version still absent lands. That landing is the §2.4 case. This matches Lance 13.0.0: ConditionalPutCommitHandler writes the manifest only when the path is absent (lance-13.0.0 src/io/commit.rs, module docs), and num_retries == 0 on an Operation::Overwrite is strict overwrite with no rebase (same file, the strict_overwrite arm). OmniGraph's linear publish still uses CommitBuilder::with_max_retries(0) (crates/omnigraph-catalog/src/commit.rs).

  4. 413 for max_gate_hold. Resolved. §3.1 now answers an over-ceiling value with 400 bad_request. http_contract.rs still uses 413 for a body that exceeds its route limit before admission.

Blocking

1. Graph deletion can restore the full-list wedge, and it can close a doubt while history can still answer

§3.4 settles each unresolved record of the deleted graph as graph_deleted / unknown, after the ledger records deletion intent and before the deletion's effects.

The deletion protocol already has one resume owner. The outstanding deployment stores the root and the schema contract. It writes Started before the first removal. It writes Deleted only after root absence is verified. A later owner resumes that same id and root when the manifest is already gone. Accepted deletion does not become a refusal. See the server-runtime RFC, "Deployment scope and preparation", and complete_graph_deletion in crates/omnigraph-cluster/src/deployment/execution.rs: the first pass and the resume pass both set Started and delete the root. Neither pass has a step for this record.

A crash after the root is gone and before the operation-record put, or a resume that completes Deleted without that put, leaves the records publishing. §2.4 then has no main head to walk. The cluster-wide list stays full. Admission on every graph stays 409. That is the wedge this section exists to close.

The set of records is also short. From step 3 of §2.3 until step 4, a publishing record is the active record, not an unresolved one. A kill in that window leaves it active. Deletion, as written, does not touch it. After the graph is gone, that one active slot stays publishing for the whole cluster.

graph_deleted is also early. A terminal record is never rewritten (§2.4). A put that marks unknown while the root still matches its contract throws away an answer first_parent_page can still give. A later resume cannot correct it.

Name the lifetime schema_identity_domain. That is the contract field the ledger already stores, and it is the field recovery compares so a replacement lifetime is refused (GraphDeletion.contract in the same executor). "Ledger lifetime" is not a field name.

Required rule:

  • Settle every non-terminal record of that graph lifetime, the active publishing record included.
  • Do it on the resume path as well as the first pass, including when the root is already absent, and do it before the deployment can record Deleted.
  • While the root is still readable, run the §2.4 walk. Use graph_deleted / unknown only once absence is verified and the walk cannot run. A missing root then ends that record. It does not fail admission for the cluster.

The testing row that deletes the graph should cover the resume after Started, with the root already absent, and a publishing record left in the active slot.

2. The generic record does not fit the second kind used to justify it

§2.6 says §2.1 to §2.5 are generic, a kind cannot change the walk, and a later kind adds a value and a row, not a file. The merge column is there so the split can be checked. The check fails.

  • The header stores one active operation. The merge row gives each target branch its own active operation, independent of the maintenance slot. One field cannot hold both.
  • §2.4 walks main, tests main's graph head, and stops on main's versions. The walk "cannot be changed", while the kind supplies the settlement branch. A merge onto another branch cannot use that walk.
  • The header stores each graph's resume position. §2.6 calls that position optimize's kind-local state. A second kind with its own position has no field.
  • §3.1 says lookup and cancel use the grant of the record's kind. §3.2 requires maintain on main on every route.
  • The unresolved cap is cluster-wide, and §2.4 settles a doubt by a publication on that graph's main. A merge in doubt would take one of those four slots, and §2.4 would not settle it.

commit_updates_on_branch_with_expected is a real shared helper. Merge already calls it (crates/omnigraph/src/exec/merge.rs). The nonce callback can stay shared. The header, the walk's branch, the cap, and the grant cannot stay both optimize-shaped and kind-generic.

Pick one shape before implementation freezes the file. Either the record in this RFC is the maintenance record: one active slot, settlement on main, and §2.6's merge column is a later change to the header and to §2.4. Or the header stores active operations by admission scope, and §2.4 takes the kind's settlement branch. A format written the first way and described the second way is the liability five later kinds will pay.

3. Two submissions can both pass before either acceptance put

§3.3 registers the owned operation first and makes the acceptance write that operation's first step. That closes the spike's hole, where a disconnect between the put and registration left an accepted id that never ran. The same paragraph says the admission slot is the record's active field, and that the record and the registry agree.

Those two slots do not agree until the put lands. OperationRuntime::submit (crates/omnigraph-server/src/operations.rs) increments the in-memory owner and spawns the future. The handler awaits the answer after submit returns. The active field is written inside the spawned future. §3.2 step 5 reads the record. Two POSTs can both see an empty active field and both start.

Deployment does not use the ledger field for this exclusion. apply takes DeploymentRuntime's mutex with try_lock_owned before submit. RunningDeployment holds that guard until the owned operation drops (crates/omnigraph-server/src/deployment.rs). A second POST is refused while the first operation is in flight, including before the ledger CAS.

Say the same thing here. The object the next admission reads has to include the registered operation, and the check and the registration have to hold one gate across the acceptance put. The record field becomes the slot after that put. Until then, the in-process owner is the slot.

4. The eviction mark does not stop a retry of the evicted id

§2.5 stores the server acceptance time of the most recently evicted record. It compares that time with the ULID time inside the caller's operation_id. Those are different clocks. The same paragraph says so, and then says the mark refuses only an id older than every retained record, and never a fresh one.

A client clock ahead of the server mints an id whose ULID time is later than the acceptance time the server stores. After that record is evicted, a retry of the same id has a ULID time after the mark. The id is no longer in the file, so admission treats it as new and runs the work again. That is the replay this mark exists to stop.

A client clock behind the server mints a fresh id whose ULID time is at or before the mark. That fresh id is refused. The "never a fresh one" sentence does not hold.

Store the ULID time of the evicted operation_id, in the same put as the eviction. Compare the submitted id's ULID time with that mark. The same id is then at the mark and is refused. State that a caller whose own ULID time is at or before the mark is refused too, and mints a new id. Say the mark is absent until the first eviction, and an absent mark refuses nothing.

Optional

1. A missing link is not a short page

first_parent_page returns Ok(None) when the start id is absent, and Err from missing_parent when a later parent is absent (commit_graph.rs). A successful page ends at next or at the root. §2.4's three endings already keep both failures in publishing. An implementer who reads "ends the page at that missing link" as Ok(Some(page)) and treats Ok(None) as not_committed would close a doubt the history did not answer. Map both results to publishing.

2. The pending server-runtime amendment still names the old object

This pull request's amendment on docs/rfcs/2026-09-29-server-runtime-and-online-deployment.md still adds __cluster/maintenance.json "for maintenance operations". The RFC body now says __cluster/operations.json and "recorded operations, of which optimize is the first kind". Acceptance should copy one sentence. Update the pending amendment so it names operations.json.

3. The new authority test drops the served limit

§1.2 recaptures authority under the held gates for the served run. The direct path keeps today's capture, and a mismatch there still refuses. The new testing row says a write that lands between the request and the gates does not refuse the run, with owner integration/maintenance.rs. Say the row is the served path. A direct or embedded caller keeps the refusal, with BeforeEffect on that refusal.

revalidate_write_txn reports a moved head as manifest_read_set_changed. ApiError::translated maps that conflict with completion_uncertain false on this tree. The BeforeEffect label is still the right mark for §1.7. On this tree it is not what keeps that typed conflict from containing the process.

Tradeoff and liability

The serving process remains the right owner. One publication remains the right door. Keeping today's gates, with a finite hold, remains the right first cut. This slice gives up low write latency during a run. A new gate shape would be a second design.

The resolution walk now has one owner, CommitGraph::first_parent_page. That removes the second walker the last review blocked. History remains the source for whether the commit exists. The nonce remains the lookup key.

The new generic layer adds liability the optimize-only record did not have. The durable header, the walk, the cap, and the grant are each specified twice, and the two specifications disagree. Five later kinds that follow the header will not fit the §2.6 rows, and five kinds that follow the rows will each revise the file. The callback on commit_updates_on_branch_with_expected is the part that should be shared. The header should match the one kind this RFC actually admits.

Graph deletion adds a second writer of a terminal fact beside history, outside the deletion state machine. A terminal unknown written before absence is verified can drift from a history that can still answer, and a resume that skips the write brings the wedge back. One resume step, in the deletion owner, is the smaller rule.

The eviction mark adds a clock comparison that does not own the anti-replay rule. The rule is "this id, once evicted, does not run again". The id's own ULID time is the value that states that. The server acceptance time states a different fact.

No GQT case fits this RFC. The contract is the control object, the HTTP admission, and completion evidence. A .gqt case cannot see those. The implementation's regressions belong in the owners in the testing table. The deletion row should include the resume and active-slot cases in blocking finding 1. Logical rows stay unchanged; the existing before/after comparison in integration/maintenance.rs is the right owner for that, not a new logic test.

Validation

Reviewed head 2413b5a861f458ea4904491507d4d3ec24a41ca8. gh pr view 1214 returned that oid. The pull request was open. Issue comments were the 2026-10-31 review (ba6e2b4a) and the 2026-11-02 design spike. Reviews and inline review comments were empty. No comment reviewed this head.

git diff --stat ba6e2b4a..2413b5a8 is one file, +259 / −115. git diff --check on that range is clean. Merge base with origin/main is 7b3a1c9190730d31391c225a63c7b6171146bc74. origin/main is 3e0e467e, four commits ahead, none of them in catalog publication, first_parent_page, optimize, or deployment deletion. Full range against the merge base remains five documentation files, +1376 / −3.

Code and Lance reads on origin/main 3e0e467e and Lance 13.0.0:

  • CommitGraph::first_parent_page and missing_parent (commit_graph.rs)
  • complete_graph_deletion and the deletion resume pass (deployment/execution.rs)
  • DeploymentRuntime gate and OperationRuntime::submit (omnigraph-server)
  • commit_updates_on_branch_with_expected and the merge caller
  • revalidate_write_txn, manifest_read_set_changed, ApiError::translated
  • MAX_RESULTS, MAX_RESULT_BYTES, MAX_RESULTS_BYTES (deployment.rs: 32, 1 MiB, 4 MiB)
  • Lance 13.0.0 src/io/commit.rs (ConditionalPutCommitHandler, strict_overwrite) and src/dataset/write/commit.rs (with_max_retries)
  • crates/omnigraph-catalog/src/commit.rs (with_max_retries(0) on the linear path)

python3 scripts/check-docs.py did not run. The machine has no documentation virtual environment, and the checker refused to start without scripts/requirements-docs.txt. git diff --check is the whitespace check that did run.

Open work read as context, not as shipped behavior: #1175 and #1176 are still open and are still the dependencies this RFC names. #1184 is still open. #1204 is the open engine draft. #1205 and #1206 are not open. #1217 and #1221 change lease fencing and are not a requirement on this text.

Limitations

Engine tests were not run. They do not exercise this document. The design spike's file:// runs were not re-run here. S3 and Azure were not executed. I did not re-fetch the Lance transaction page; the pinned crate source is the check for the late-put sentence. I read the deletion and admission paths this delta now depends on. I did not re-review #1175 or #1176 line by line.

Recommendation: revise.

The review of the generic record found the header optimize-shaped while
the prose called it generic. The header now keys active operations by
admission scope and each kind's state by graph and kind, a record stores
its settlement branch, and the resolution walk, the exit from a full
unresolved list and the grant on lookup and cancel follow the record.

Graph deletion settles records as a step of complete_graph_deletion on
both passes, before Deleted, covering an active publishing record,
walking while the root is readable and writing graph_deleted only after
verified absence; the lifetime is schema_identity_domain. Each scope has
an in-process gate held across the acceptance put, so two submissions
cannot both pass the active-slot check. The eviction mark is the evicted
id's own ULID time. Ok(None) and Err from first_parent_page both keep a
record publishing. The pending server-runtime amendment names
operations.json, and the authority testing row is the served path.
The record, file, routes and id are operations. Maintenance names one
admission scope, the kinds the maintain grant covers; seven lines still
read as if it named the record.
@cursor

cursor Bot commented Nov 2, 2026

Copy link
Copy Markdown

Recommendation: revise.

Why

A served graph cannot run optimize while the server holds the cluster lock. This RFC gives that work to the process that already serves and publishes the graph. The caller chooses an operation id. The server stores the request before any effect. The engine still makes one graph publication. Lookup answers a lost response. Restart reads the graph and does not run the work again.

What

This pull request is a design document. The reviewed range adds no executable code.

The current head is b2a9ab5ed35ef9586739073910ae607d8d28a25f. The previous review was at 2413b5a861f458ea4904491507d4d3ec24a41ca8. Two commits sit between them. The first makes the record header match its kinds. The second names the maintenance scope. I reviewed that delta. I did not repeat findings the new text already closes.

Prior findings

  1. Graph deletion settlement. Partly resolved. The step sits in complete_graph_deletion, on the first pass and the resume pass, after Started and before Deleted. It covers an active publishing record. The lifetime is schema_identity_domain, the field GraphDeletion.contract already stores and the resume path already compares (deployment/execution.rs). graph_deleted waits for verified root absence. The walk can still stop short of an answer, and the same step then makes that record terminal. That remainder is blocking finding 1.

  2. Generic record versus the merge kind. Resolved. Active operations are keyed by admission scope. Kind-local state is keyed by graph and kind. Each record stores its settlement branch, and §2.4 walks that branch. Lookup and cancel use the record's grant. The merge row fits those fields. One cell still has no owner. That is the optional finding.

  3. Admission race before the acceptance put. Resolved. The handler takes the scope mutex with a try-lock before submit, and the owned operation holds it until drop. That is the deployment gate (DeploymentRuntime::apply and RunningDeployment). Two submissions in one scope can no longer both read an empty active field. Until the put lands, the in-process owner is the slot.

  4. Eviction mark clock. Partly resolved. The mark is a ULID time, written in the eviction put, and absent until the first eviction. The server acceptance clock is out of the comparison. The stored value is the latest eviction, so a later eviction can move the mark backward, and admission applies the mark before the same-id match. That remainder is blocking finding 2.

  5. Missing link versus a short page. Resolved. Ok(None) and Err (missing_parent) both stay publishing. CommitGraph::first_parent_page returns those two results (commit_graph.rs). The testing row names both.

  6. Server-runtime amendment. Resolved. The pending entry names __cluster/operations.json and recorded operations.

  7. Authority testing row. Resolved. The row is the served path. A direct or embedded caller keeps the refusal. revalidate_write_txn still reports a moved head as manifest_read_set_changed. ApiError::translated maps that to a read-set conflict, and ApiError::new sets completion certain. BeforeEffect is the §1.7 label.

Blocking

1. Deletion can close a doubt the history can still answer

§3.4 walks while the root is readable, then writes graph_deleted for every record that is still non-terminal once absence is verified.

complete_graph_deletion deletes the root through delete_managed_graph_root. That helper removes the prefix and then checks that the root is gone. Both passes record Deleted only after the helper returns. A record left publishing at that point becomes graph_deleted. A terminal record is never rewritten (§2.4).

Two endings leave the record publishing while the root can still answer:

  • §2.4 reads one page and stores next when the child is below the page. A deletion that reuses that attempt, then removes the root, drops the pages that would have judged the child. There is no later attempt. "To its end" has to mean a settled child or a gap. A stored cursor is not that end. The deletion step repeats pages until each record is succeeded, not_committed, or a gap. resolution_walk_limit stays the admission bound.
  • A failed page read is the Neither ending. It does not show that the commit is absent. The resume path already retries a deployment left at Started. The history read belongs in that retry, before delete_prefix. A failed read returns the error the helper already returns. It leaves Started. It leaves the record publishing. It does not remove the root.

Ok(None) and missing_parent are the gap. Those records take graph_deleted after absence is verified, and on a resume that finds the root already absent.

The deletion row should add two cases. The child sits deeper than one page, and the walk reaches it before the root is removed. A failed history read with the root still present leaves Started, leaves the record publishing, and leaves the root in place.

2. The eviction mark can move backward, and it can refuse a record the file still holds

§2.5 stores the ULID time of the most recently evicted operation_id, and the next eviction replaces it. The terminal list is newest first, and the oldest entry is evicted first (§2.2). That order follows acceptance. ULID time follows the caller clock. The paragraph already says those clocks differ.

One sequence:

  • Caller A finishes an id whose ULID time is 1000. That record is the oldest terminal.
  • Caller B finishes an id whose ULID time is 100. The file still holds it.
  • Eviction removes A's id. The mark becomes 1000.
  • A POST of B's id has ULID time 100. §3.2 step 2 requires an id newer than the mark, so the POST is operation_id_expired. The file still holds B. The identity rule says that POST returns B's operation.
  • Eviction later removes B's id. The mark becomes 100.
  • A POST of A's id has ULID time 1000, which is newer than 100. A is absent, so admission treats it as new and runs the work again.

The mark is the greatest ULID time of any id this file has evicted. A put that evicts ids sets the mark to the greater of the stored mark and those times. The mark never decreases. An absent mark still refuses nothing.

Admission matches an id the file still holds before it reads the mark. That POST returns the stored operation. The mark applies to an id the file does not hold. The same id is then at or before the mark and is refused. A fresh id at or before the mark is refused too, and that caller mints an id with a later ULID time.

The testing row should add both cases. Thirty-two later ids from one clock leave the mark equal to the last evicted id, so that row stays green on this text.

Optional

1. Branch deletion is named and has no owner

§2.4 says deletion of a non-main settlement branch settles that record. §2.6 names the reason branch_deleted. No step names the function. Omnigraph::branch_delete and GraphCoordinator::branch_delete_captured are the branch-deletion owner today.

The later kind adds the step there, in the shape of §3.4. Walk while the branch is readable. Write branch_deleted only after the branch is gone, including a resume. This RFC's optimize records settle on main. This RFC does not add that step. §2.4 should say the later kind owns it.

The merge row also says the kind bounds the number of scopes, and it gives no number. The size proof in §2.2 is one scope plus four unresolved records. The later RFC names the number. Until then the proof covers the maintenance scope only.

Tradeoff and liability

The serving process remains the right owner. One publication remains the right door. Today's gates, with a finite hold, remain the right first cut. This slice gives up low write latency during a run. A new gate shape would be a second design.

The header change removes the split between generic prose and an optimize-shaped file. Cleanup, repair, and full-text rebuild add a kind and a start route inside the maintenance scope. They pass the same callback. They call first_parent_page. They do not add a file, a walker, or a gate. Merge adds scopes and one branch-deletion step. That is the shape five later kinds can share.

The deletion step belongs in complete_graph_deletion, because both passes already call it and Deleted is written only after it returns. That keeps one writer of the terminal fact. The remaining liability is a graph_deleted record written while a later page, or a retry of a failed read, could still answer. A terminal record cannot be corrected.

The eviction mark should stay one field. A list of evicted ids would be a second retention store. The field is a high-water mark, because the terminal list and the ULID time are different orders. An id the file still holds is answered by the file. The mark is only the rule for an id the file has dropped.

The scope gate copies the deployment gate. It adds no lock type.

No .gqt case can see this contract. The contract is the control object, HTTP admission, and completion evidence. The implementation's regressions belong in the owners in the testing table. The deletion row and the eviction row need the cases in the two blocking findings.

Validation

Reviewed head b2a9ab5ed35ef9586739073910ae607d8d28a25f. gh pr view 1214 returned that oid. The pull request was open. Issue comments were the 2026-10-31 review (ba6e2b4a), the 2026-11-02 design spike, and the review of 2413b5a8. Reviews and inline review comments were empty. No comment reviewed this head.

git diff --stat 2413b5a8..b2a9ab5e is two documentation files, +211 / −133. git diff --check on that range is clean. Merge base with origin/main is 7b3a1c9190730d31391c225a63c7b6171146bc74. origin/main is 3e0e467e39756355c981c2f598f63d92a394529f.

This RFC changes no behavior a .gqt case can show. There is no before/after logic run.

python3 scripts/check-docs.py at this head reported Documentation OK (441 Markdown files checked). The run used the packages in scripts/requirements-docs.txt.

Code reads on origin/main 3e0e467e:

  • CommitGraph::first_parent_page and missing_parent (commit_graph.rs)
  • GraphCoordinator::first_parent_page and open_branch_with_session (graph_coordinator.rs)
  • complete_graph_deletion, both callers, and the schema_identity_domain compare (deployment/execution.rs)
  • delete_managed_graph_root (store.rs)
  • DeploymentRuntime gate, RunningDeployment, and OperationRuntime::submit
  • revalidate_write_txn and ApiError::translated / read_set_conflict
  • MAX_RESULTS, MAX_RESULT_BYTES, MAX_RESULTS_BYTES (deployment.rs: 32, 1 MiB, 4 MiB)
  • branch_delete and branch_delete_captured
  • next_head, which sets graph_manifest_version to the version that attempt writes (publisher.rs)

Main since the merge base renames first_parent_chain_holds to descends_linearly_from. That helper stops at a merge commit. first_parent_page is unchanged and does not stop there. The RFC names the right helper. #1220 is that merge-window fix and is on main. It is not a new requirement on this text.

Open work read as context: #1175 and #1176 are still the dependencies this RFC names. #1184 is still open and still does not touch this walk. #1204 is the open engine draft. #1205 and #1206 are not open. #1217 and #1221 change lease fencing and are not a requirement on this text.

Limitations

Engine tests were not run. They do not exercise this document. The design spike's file:// runs were not re-run here. S3 and Azure were not executed. I read the deletion, admission, and commit-graph paths this delta depends on. I did not re-review #1175 or #1176 line by line.

Inline comments

docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md:965 (RIGHT)

Blocking. The walk has to finish, or fail closed, before the root is removed.

complete_graph_deletion calls delete_managed_graph_root, which removes the prefix and then checks that the root is gone. Both passes record Deleted only after that return. §3.4 then writes graph_deleted for every record still non-terminal. A terminal record is never rewritten.

A page that stores next has not judged the child. A failed page read has not shown the commit absent. Either ending becomes unknown once the root is gone, and no later attempt can read the history.

Repeat pages until the record is succeeded, not_committed, or a gap (Ok(None) or missing_parent). A failed read leaves Started and publishing and does not call delete_prefix. graph_deleted stays the ending for a verified absence, and for a gap once absence is verified.

docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md:783 (RIGHT)

Blocking. The mark has to be the greatest evicted ULID time, and a held id has to match first.

The terminal list evicts the oldest entry. ULID time follows the caller clock. Replacing the mark with the latest eviction lets the mark move backward. A retry of an earlier id is then newer than the mark and runs again. The same replacement refuses a POST of an id the file still holds, because §3.2 step 2 runs before the same-id match.

Set the mark to the greater of its current value and the ULID times evicted in that put. Match an id the file holds before reading the mark. Apply the mark only to an id the file does not hold.

Recommendation: revise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant