Skip to content

rfc: remote optimize through the serving owner - #1190

Merged
ragnorc merged 11 commits into
ModernRelay:mainfrom
ragnorc:rfc/served-optimize-v2
Oct 29, 2026
Merged

ragnorc merged 11 commits into
ModernRelay:mainfrom
ragnorc:rfc/served-optimize-v2

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

A graph served from object storage cannot be optimized on 0.14. optimize runs only in a direct process, a direct target is local-only, and a direct process beside a live server refuses on the cluster lock. Fragment debt therefore grows without bound on every served S3 graph. The earlier proposal (#1168) was closed because it mixed engine, server and control-plane work without an ownership model, made acceptance ephemeral with blind retry, and added concurrency changes that interacted badly. Its closing review asked for focused RFCs that start with a bounded, manually requested remote optimize through the existing owner. This is that first RFC, covering the engine and the server together.

The engine keeps today's gate shape and its one publication. What changes is that a request bounds how long the run keeps starting tables while it holds the writer gates. Tables are worked one at a time and reported one by one (optimized, nothing to do, deferred, failed), and a before_publish callback receives the intended graph commit id before the publication. The server records the request in a new __cluster/maintenance.json, written only by the lock holder, under an operation id the caller chose. The record is durable before any effect. The server executes the request as an owned operation, answers lookups by that id after a lost response, and on restart resolves an unfinished record by looking for the intended commit in main's history; it never executes the run again. Once its single write is issued, the adjacency artifact refresh runs to completion and is reported separately, so it never leaves an uncertain storage write behind a certain publication. Three graph-scoped routes (submit, look up, cancel) reuse existing error codes, and omnigraph optimize dispatches to them for a server target.

I checked the design against current main (dac660fb): the optimize gates and the point where it creates its lineage intent (optimize.rs); the deployment ledger and its retention (omnigraph-cluster), whose deny_unknown_fields state is why the record is a separate object; the owned-operation and shutdown contract; the HTTP conventions; and the error-code registry. check-docs.py, check-agents-md.sh and typos pass. There is no code change.

For review: the decision to challenge is keeping today's gate hold and bounding it, rather than changing how maintenance coexists with writers. Writes to main wait up to the bound plus one table's work plus the publication, and a queued schema apply or branch create stalls other writers for that long. The second is where the record lives and what it holds: a separate control object, four conditional writes per run, a 32-record, 4 MiB bound, and unknown (never "expired") for an id it no longer holds. The third is caller-chosen ULIDs as operation ids. The RFC files pending amendments to RFC 1017's exception table, RFC 0011's Decision 11 and the server-runtime RFC's "no second durable operation record". It also states which parts of #1175 and the failure-model branch it reuses, and that cleanup, automatic triggers, workers and the coexistence redesign are out of scope.

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.
@ragnorc
ragnorc marked this pull request as ready for review October 28, 2026 18:54

ragnorc commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: revise.

Why

A served graph on object storage 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 the 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 at most one graph publication. Lookup answers a lost response. Restart reads graph history 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 engine keeps today's schema gate, main branch gate, and table gates, and it stops starting tables after a time bound. The server adds __cluster/maintenance.json, three graph routes, and a CLI path for a server target. Cleanup, schedules, external workers, and a new way to share the gates with writers are out of scope.

How

Acceptance is a conditional put of the operation record. The run then holds the writer gates, works one table at a time, and publishes the finished tables once. A callback records the intended commit before that publication. On restart, a publishing record becomes succeeded when that commit is in main's history. Any other unfinished record becomes interrupted.

Impact

The serving process is the right owner, and the lost-response shape matches deployment. The recovery key, the writer-wait bound, and the per-table result do not yet match the code this RFC cites. Fix those in the document before an implementation follows it. Do not accept pull request #1186 beside this one.

Alignment with #1186

#1186 (head 5d5bf01ee04a327a4116c8ae6524a5e1d8bd50f3, merge base 790174c7) also runs optimize in the serving process and keeps one graph publication. The HTTP call waits for the report. There is no operation id, no stored result, and no lookup. A lost response stays unknown, and the caller must not send the request again on its own.

That smaller contract avoids a second durable record. The server-runtime RFC currently forbids that record. It also leaves the operator unable to learn what a finished run did. #1186 cites control-plane text that requires a native operation id and lookup (LCY-04.2 and DEC-08-76), then asks that text to allow a permanent unknown. Deployment on this server already uses a caller-chosen id, stores acceptance before work, and answers a lost response by that id.

This RFC is the contract that fits that pattern, after recovery uses the commit id the publisher writes. Two accepted optimize RFCs would leave two contracts.

Bring these rules across from #1186:

  • Enforce table, fragment, byte, and Blob ceilings before a rewrite. A time bound does not bound memory or storage traffic.
  • Keep today's drift skip and deferred full-text coverage as their own results.
  • Leave full-text rebuild as its own operation.
  • Record the input commit the publication used, while the gates still hold that head.

Reuse the maintain action from #1175. #1186's separate optimize action would be a second name for the same work. On that branch, admin does not grant maintain.

Keeping today's gates is the right first cut. A schema apply or branch create that queues during the run stalls later writers, because the schema gate is write-preferring (write_queue.rs). The bound is what keeps that stall finite. A new gate shape is a second design. It does not belong in this RFC.

Blocking

1. The live boundary is the cluster lock

operations.rs:624 does list optimize as direct-only. A direct target is not local-only. targets.rs accepts a path, file://, s3://, and az://. run_optimize opens that URI. When the graph belongs to a cluster, ensure_graph_with_binding takes the cluster lock. acquire_lock then refuses with state_lock_held if the server holds it. When the lock is free, direct optimize of an object-storage graph runs today. Open #1189 continues that direct object-storage path.

Say this boundary in the summary. Keep direct optimize for a stopped server, on local and object storage.

2. The callback site does not yet know the published commit id

optimize.rs:327 calls new_lineage_intent_for_branch. That value is a nonce ULID (LineageIntent.graph_commit_id in graph_coordinator.rs). The id written into history is chosen later, in GraphNamespacePublisher::attempt_commit_id, and it has the form hb1.{block}.{slot}.{nonce}. The block and the slot come from the parent and the commit buffer of that attempt.

commit_id_answers already treats a history commit as the answer to its nonce. A search for the bare nonce does not find hb1.... Restart would mark a published run interrupted.

Put the hook on the publish attempt, after next_head and before archive_schema, settle_closed, and merge_rows. Store the nonce. Match history with commit_id_answers. If the attempt runs again, run the hook again before that attempt writes. A failed hook publishes nothing.

ExactGraphHead refuses a moved head. It does not re-parent. An id guessed before load_publish_state can still differ from the id of the winning attempt. The nonce stays stable.

3. An in-doubt publication has to stay non-terminal

A terminal record is never rewritten. failed or interrupted while the manifest put is in doubt would hide a commit that lands later.

The accepted storage-outcomes RFC already changed this case. An ambiguous manifest request answers outcome_unknown. The server keeps serving. The next publication on that branch fences the version. Exit 2 remains for a panic, a dropped write owner, RecoveryRequired, an opaque DataFusion error, and an uncertain deployment activation. It is not the result of every uncertain storage write.

A live process therefore has to resolve a publishing record by the nonce, in the same way as restart. It should not wait for the process to exit. accepted or running means unpublished only when no attempt has sent a manifest request. That is true when the hook runs before the request. It is false after merge_rows has been sent.

DataFusion with no completion evidence still contains the process (ApiError::translated). Mark optimize failures before the publication as BeforeEffect or Unpublished. Do not use that label after the graph commit. clear_manifest_auto_cleanup fails the whole call today, and it runs after the publication.

4. The writer-wait bound omits work that still holds a gate

The schema permit and the table guards drop at optimize.rs:352. The main branch guard stays until the function returns. Two later steps still hold it:

  • persist_graph_index_artifact scans every edge and writes __graph_index/csr-current.bin. The load ceiling is 4 GiB (MAX_ARTIFACT_BYTES in graph_index/persist.rs).
  • clear_manifest_auto_cleanup opens __manifest at linear HEAD and can delete lance.auto_cleanup.* with update_config. That is a linear catalog commit. It is outside the graph publication. The returned stat hard-codes committed: false even when that commit happened.

The stated wait is max_gate_hold plus one table plus the publication. The real wait is longer. Drop the branch gate before the artifact scan. Report the config clear next to the artifact, after the graph commit. A 4 GiB object write is not known to fit the shutdown grace. Await that write only after it has been issued, and say what SIGTERM does while it is still running.

reserve_export_destructive_control is held from the first line of optimize_all_datasets. Include that exclusion in the bound.

5. Stable order plus the time bound can skip the dirty tail

Tables run in request order, then by type key. The next default request starts at the same key. Each clean table is planned again before a later table can start. prepare_optimize_table opens the table, plans compaction, and loads index coverage. When that prefix costs more than max_gate_hold, the run defers the rest, and a dirty later table never starts.

The 576.8 s figure is a full no-op plan of 657 tables from a remote runner. It is not in this repository. It shows that a 60 s default does not cover that catalog in one request. Today's plan is also up to 8 tables at once (DEFAULT_MAINT_CONCURRENCY, overridable by OMNIGRAPH_MAINTENANCE_CONCURRENCY). Planning one table at a time makes the same prefix cost more.

The deferred list is the continuation. The next full-catalog request, and the CLI, start at the first deferred table and use a new operation id. The gates drop between ids, so writers run between slices. A request that passes tables can already do this. The default command needs the same rule.

6. The result list drops two current outcomes

A drifted table is SkipReason::DriftNeedsRepair today. The run leaves that table alone and continues. Foreign linear movement stays a repair finding. The new list is optimized, nothing to do, deferred, or failed. A run-level failure on drift would discard tables that already finished. A per-table drift result is safe only when the other tables still publish.

Full-text coverage stays pending. TableStore::can_fold_index rejects a full-text index. append_deferred_full_text_indexes records the gap and names rebuild-full-text-indexes. Section 1.2 ("index catch-up") and section 1.7 (full-text builder caps) read as if optimize rebuilt those indexes. The out-of-scope list says the rebuild route stays out. State the fold in one place: stage_index_fold still rebuilds a lagging scalar or vector index whole. Full-text stays pending. A vector column that cannot train stays pending. Those are results, not failures. #1156 remains the owner of any incremental fold.

Stock Lance compact_files says a compaction can change row ids. TableStore::stage_compaction refuses an address-style rewrite and keeps stable row ids. "Logical data is unchanged" depends on that path. Old fragment files remain until cleanup. The config-key delete in finding 4 is the extra write.

7. Lookup, cancel, and a repeated POST need one auth and status rule

maintain is checked at acceptance. GET and cancel are not. Both return the principal, the options, and the outcomes. Cancel stops the run. Both require maintain on that graph's main branch.

POST of the same id and the same request returns 200 with the existing record. Deployment returns 202 until the work is terminal, and 200 when it is done (deployment.rs). A 200 for a record still in running looks complete. Return 202 while the record is non-terminal. Return 200 when it is terminal.

unknown means this record does not hold the id. It does not prove the run never happened, and it does not allow a replay. The CLI never resubmits, and it does not say what it prints, or which exit status it uses, for unknown or different_cluster. State both. A new id is a new operation and can repeat the work.

different_cluster means this file's ledger id is not the server's ledger id. It does not mean the id lives in another cluster. A cluster whose ledger_id is still empty needs a rule too.

8. The four record puts are not definite on this base

StorageAdapter::write_text_if_match returns on the first error. It does not re-issue. The accepted storage-outcomes RFC requires a re-issue and a byte compare for control objects. That work is open as #1176. It is not on main.

An ambiguous acceptance put is the lost answer this record is here to close. Name #1176 as a dependency. Do not start table work, and do not publish, while one of the four puts is in doubt. Read the object back. Equal bytes mean this write landed.

Optional

  • Put feat(policy)!: gate maintenance behind the maintain and reclaim actions #1175, or the maintain / optimize_as contract, in blocked_on. Those symbols are not on main. maintain is branch-scoped. It does not include reclaim.
  • Store the head the publication used as its parent. The head at HTTP acceptance can be older, because writes still run until the gates are taken.
  • One active maintenance operation per cluster is a real limit. Other graphs in that cluster wait. It is a sound first slice when kind is the only extension for cleanup, repair, and full-text rebuild.
  • Choose the 60 s default from a measurement on the serving host, in region. The remote 576.8 s plan and the 286.8 s productive run are not in the tree. The 10–14× read figure matches the server-runtime RFC's local screen (about 13.8× at :1246-1251). That text already says index creation confounds the result.
  • A restart that marks publishing as succeeded does not refresh the artifact. When an older artifact exists, the next no-work optimize skips the refresh. Loads rebuild the adjacency in memory, so answers stay the same. Say that.
  • The one-table rule removes OMNIGRAPH_MAINTENANCE_CONCURRENCY. Say so, or the variable and the RFC will disagree.
  • Keep unknown for an id this record does not hold. A list of evicted ids can say "not retained" only when that list is written in the same put as the eviction. That answer still must not allow a replay.

Tradeoff and liability

The record is the right place for facts the manifest cannot store: a refusal, a no-op, a cancellation, a deferral, and the principal. It is the wrong place for a second copy of the commit. History already holds that commit.

This design removes the need to stop the server, and it removes the blind retry from the closed proposal. It adds one control object, a caller-chosen id, four conditional puts, a 32-record cap, and one active slot. Those are worth keeping.

It also adds three costs. The callback can record an id the publisher has not assigned. A terminal state can freeze an in-doubt put. The time bound ignores the branch gate and the next slice of the catalog. Those costs add long-term liability. Five similar operations (cleanup, repair, full-text rebuild, index build, named-branch optimize) should add a kind and a route. They should not add a file, a callback, and a restart story each.

One owner for the commit id

Three places talk about the same identity: the lineage nonce, the hb1 id, and the operation record. commit_id_answers and intent_nonce already define the match. The publish attempt is the code that knows the id before the put. A hook there makes "stored before the manifest request" true by construction. Optimize calls that hook. A later maintenance operation calls the same hook.

The operation record should be the one server object for maintenance kinds. __cluster/state.json stays deny_unknown_fields, so a separate object is right. It should not grow a second copy of deployment receipts.

Validation

Reviewed head 2a6c6c4d66a79a9d69850ab22dd5e6b8e137f189. gh pr view 1190 returned that oid. The pull request was open. Reviews, review comments, and issue comments were empty.

Merge base with main is dac660fb8d0229e1308fed7ab20cfc86365bb265, which is origin/main. The diff is five documentation files, +588 / −3. No Rust file changes. This RFC changes no behavior a .gqt case can show, so there is no before/after logic run. A green GQT run would not test the document.

git diff --check dac660fb8d0229e1308fed7ab20cfc86365bb265 2a6c6c4d66a79a9d69850ab22dd5e6b8e137f189 is clean.

python3 scripts/check-docs.py in a detached checkout of that head reported Documentation OK (417 Markdown files checked), after python3 -m pip install -r scripts/requirements-docs.txt.

The code checks are reads of dac660fb:

  • crates/omnigraph-api-types/src/operations.rs:624
  • crates/omnigraph/src/db/omnigraph/optimize.rs (gates, concurrency, publication, artifact, config clear)
  • crates/omnigraph/src/db/graph_coordinator.rs (nonce)
  • crates/omnigraph-catalog/src/publisher.rs (attempt_commit_id, retry loop)
  • crates/omnigraph-core/src/graph_commit_id.rs (commit_id_answers)
  • crates/omnigraph/src/table_store.rs (can_fold_index, stage_compaction, stage_index_fold)
  • crates/omnigraph/src/db/write_queue.rs (write-preferring schema gate)
  • crates/omnigraph-cli direct URIs and admission
  • crates/omnigraph-cluster state_lock_held
  • crates/omnigraph-storage write_text_if_match on this base
  • crates/omnigraph-server completion evidence and the deployment 202/200 split
  • Lance 13.0.0 src/dataset/optimize.rs compact_files
  • The cited lines in the three amended RFCs (:284, :536-547, :811, :1246-1262) match this base

Limitations

Engine tests were not run. They do not exercise this document. The 576.8 s and 286.8 s runs were not measured here. Lance HTML guides were not re-fetched. The compaction and row-id claims come from the pinned Lance 13.0.0 crate and from stage_compaction. S3 and Azure admission were not executed.

Inline comments (same review, side RIGHT, commit 2a6c6c4)

File: docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md

Line 24. Blocking. A direct target is not local-only.

targets.rs accepts a path, file://, s3://, and az://. run_optimize opens that URI. When the graph is in a cluster, ensure_graph_with_binding takes the cluster lock, and acquire_lock refuses with state_lock_held while the server holds it. When the lock is free, direct optimize of an object-storage graph runs today.

State that boundary. Keep the direct path for a stopped server, on local storage and on object storage.

Line 173. Blocking. This order plus max_gate_hold can leave the tail dirty.

The next default request starts at the same type key. Each clean table is planned again (prepare_optimize_table opens the table, plans compaction, and loads indexes) before a later table can start. When that prefix costs more than the bound, the deferred tail never starts.

The deferred list has to be the continuation. The next full-catalog request, and the CLI, start at the first deferred table and use a new operation id. Release the gates between ids.

Line 181. Blocking. This bound is short of the wait the current function holds.

The schema permit and the table guards drop at optimize.rs:352. The main branch guard stays to the end of the function. It still covers persist_graph_index_artifact (edge scan plus a write whose load ceiling is 4 GiB, MAX_ARTIFACT_BYTES) and clear_manifest_auto_cleanup (a linear __manifest update_config, outside the graph publication). reserve_export_destructive_control is held from the start.

Drop the branch gate before the artifact scan. Report the config clear after the graph commit, beside the artifact. Say what SIGTERM does while an issued artifact write is still running.

Line 206. Blocking. This call site has a nonce, not the id history will store.

new_lineage_intent_for_branch (optimize.rs:327) stores a ULID nonce. GraphNamespacePublisher::attempt_commit_id later writes hb1.{block}.{slot}.{nonce}. The block and slot come from the parent and the commit buffer of that attempt. commit_id_answers is the match for the nonce. An exact search for the nonce misses the commit, so restart would mark a published run interrupted.

Put the hook after next_head and before archive_schema, settle_closed, and merge_rows. Store the nonce. Run the hook again on a retried attempt, before that attempt writes. A failed hook publishes nothing.

Line 244. Blocking. This write is capped at 4 GiB (MAX_ARTIFACT_BYTES), and the main branch gate is still held across the scan.

"Inside the existing shutdown grace" is not shown. Await the write only after it is issued. Do not hold writer gates for the scan. A missing or stale artifact stays safe: load rejects it and rebuilds in memory.

Line 287. Blocking. On this merge base these puts are not definite.

StorageAdapter::write_text_if_match returns on the first error. It does not re-issue. The accepted storage-outcomes RFC requires re-issue and a byte compare for control objects. That behavior is open as #1176 and is not on main.

Name that dependency. Do not start table work, and do not publish, while a put is in doubt. Equal bytes on read-back mean this write landed.

Line 313. Blocking. Match the nonce with commit_id_answers, and do not write a terminal record while the put is in doubt.

History stores hb1.{block}.{slot}.{nonce}, not the bare nonce from §1.4. A terminal failed or interrupted is never rewritten, so it would hide a commit that lands later.

The storage-outcomes RFC keeps the server up on outcome_unknown and fences the version on the next publication. Resolve publishing in the live process the same way as on restart. accepted or running means unpublished only when no manifest request has been sent.

Line 92. Blocking. This list drops two current results.

SkipReason::DriftNeedsRepair leaves that table untouched and lets the others publish. Map drift to a per-table result, not a run-level failure.

can_fold_index rejects full-text indexes. append_deferred_full_text_indexes records the gap and names rebuild-full-text-indexes. Say that full-text stays pending, and that a vector column that cannot train stays pending. stage_index_fold still rebuilds a lagging scalar or vector index whole. Section 1.7's full-text builder caps contradict that split.

Line 333. Blocking. A repeated POST of a non-terminal record must not look finished.

Deployment returns 202 until the work is terminal and 200 when it is done. Return 202 while this record is non-terminal, and 200 when it is terminal.

GET and cancel return the principal, the options, and the outcomes, and cancel stops the run. Both need maintain on main. The text only checks that grant at acceptance.

State the CLI exit for unknown and for different_cluster. unknown does not prove the run never happened and does not allow a replay. A new id is a new operation. different_cluster means this file's ledger id is not the server's.

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.
@ragnorc

ragnorc commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Revised in 9f76cf29 (main merged in 30af5062). Each finding was checked against f76d6011 before the text changed.

Blocking findings

  1. Boundary. The summary now names the cluster lock as the boundary. A direct target accepts a path, file://, s3:// and az://. The direct path stays for a stopped server, on local and object storage. feat(cli): accept an inline s3:// cluster root for deployment, recovery and maintenance #1189 is listed as complementary.
  2. Commit id. The record stores the intent nonce. Resolution matches history with commit_id_answers (§1.6, §2.4). One difference from the suggested placement: the hook runs once, in table_ops::commit_updates_on_branch_with_expected, after the updates are prepared and before the publisher's first attempt. It does not run once per attempt. Every attempt reads the head GraphHeadExpectation captured and carries the same nonce, so a per-attempt put would record no new fact. It is still before every manifest request by construction. The shared helper owns it, so a later kind reuses it. The alternative is listed in Alternatives.
  3. In-doubt publication. publishing stays non-terminal until main's next version settles it, in the live process, at lookup and admission, and after restart. While main has not moved past the input head, it stays in doubt. Once main moves, the run is succeeded if history answers the nonce, and failed with not_committed otherwise. An in-doubt run leaves the active slot for a bounded unresolved list (at most 4). Failures before the publication carry BeforeEffect or Unpublished. Nothing after the graph commit fails the operation.
  4. Writer wait. The configuration clear stays under the gates. It is one read and at most one commit, and it now reports whether it committed. Every writer gate is released before the artifact scan. The served path writes the artifact only within artifact_write_limit, and it awaits an issued write. SIGTERM during that write follows the storage-outcomes rule for an unsettled mutable key. The export exclusion is in the hold table.
  5. Dirty tail. A full-catalog run resumes after the first deferred table. The server keeps that position as a hint. Every run starts at least one table. Planning keeps its bounded concurrency because it only reads. Served rewrites run one at a time. The direct path keeps OMNIGRAPH_MAINTENANCE_CONCURRENCY, and the served path ignores it.
  6. Results. The index rules now sit in one place (§1.5). Partial full-text coverage and an untrainable vector stay pending. Optimize still builds a column's first full-text postings (has_fts_postings in plan_index_work_node), so the builder caps are scoped to that case. Stable row ids rest on stage_compaction. One correction: SkipReason::DriftNeedsRepair has no producer on main. refactor: remove table commit promotion #776 removed it, and each table opens at its pin with open_pinned_for_write. The only remaining use is the CLI output test. The RFC says so. It keeps a per-table skipped outcome and adds table-local failed, so a table-level problem never discards finished tables.
  7. Auth and status. All three routes require maintain on main. A repeated POST answers 202 while the record is non-terminal and 200 once it is terminal, the same split as deployment. The CLI exits are now stated (§4). different_cluster is gone. A served ledger v2 always has a ledger id (validate_state). A record with a different ledger id is refused with 409 and never overwritten.
  8. Puts. fix(storage): re-issue and read back conditional control-object writes #1176 is in blocked_on. §2.3 says what happens when each of the four puts stays in doubt. No table work and no publication start while one is in doubt.

Optional items. #1175 is in blocked_on, and maintain excludes reclaim. The input head is captured under the gates. The 576.8 s and 286.8 s figures are marked as external and do not set the bound. The default and the ceilings come from an in-region measurement, and the route ships disabled until then. The note on artifact staleness after restart was added. Evicted ids answer unknown, with no evicted-id list.

Main's Check AGENTS.md Links job currently fails on changelog.d/ensure-indices-refreshes-coverage.changed.md (283 characters, from #1150). #1194 fixes it. This PR's docs job will fail the same way until #1194 lands.

@cursor

cursor Bot commented Oct 28, 2026

Copy link
Copy Markdown

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 graph history 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 engine keeps today's gates and its one publication. It stops starting tables at a time bound and at resource ceilings. The server adds __cluster/maintenance.json, three graph routes, and a CLI path for a server target.

How

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

Impact

The serving process is the right owner. The revision fixes the lock boundary, the nonce, the non-terminal publishing state, the route status, and the direct-path concurrency. Four rules in the new text still disagree with the publisher and with Lance 13.0.0. An implementation that follows those sentences can skip a deferred table, hide a commit, or allocate the adjacency index in the server before the size check. Fix the document before an implementation follows it. Do not accept pull request #1186 beside this one. Its head is still 5d5bf01ee04a327a4116c8ae6524a5e1d8bd50f3.

What the revision resolved

Checked against the PR tree, which has the same Rust as merge base f76d6011.

  • 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. state_lock_held refuses while the server holds it. 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. ExactGraphHead does not re-parent. LineageIntent.graph_commit_id is stable across attempts. One hook before publish_prepared is before every manifest request. A hook on every attempt would record no new fact. That part of the earlier placement request is withdrawn.
  • publishing stays non-terminal. validate_state requires a canonical ledger id on ledger v2. The three routes require maintain. A repeated POST returns 202 until the record is terminal. DriftNeedsRepair has no producer. The only construction is the CLI output test. can_fold_index rejects full-text indexes. plan_index_work_node builds postings only when has_fts_postings is false. stage_compaction refuses an address-style rewrite. fix(storage): re-issue and read back conditional control-object writes #1176 is in blocked_on, and §2.3 covers an in-doubt record put.
  • Writer gates drop before the artifact scan. The configuration clear stays under the gates. The export exclusion is in the hold table. SIGTERM during an issued artifact write follows the unsettled-key rule. The shutdown grace is OMNIGRAPH_SHUTDOWN_GRACE_SECONDS, default 25 seconds.

Blocking

1. The resume key skips the deferred table

start_after means the run starts after that type key. The wrap stops before the first table the run visits. The resume position stores the first deferred table, and the next request "starts there".

If that table is stored in start_after, the next run starts after it. The wrap then stops before it. The table never runs.

Example: keys A, B, C. The run defers at C. start_after = C does not visit C. The next run starts after C, wraps through A and B, and stops before C.

Store the last table that was not deferred, and start after that key. When the first table of the catalog is deferred, omit start_after so the next run starts at the beginning. A gate_hold_limit or byte_budget deferral is the next table. A resource_limit deferral applies to that table only, and later tables in the same run continue.

2. The first-table rule overrides the ceilings

§1.3 says the first table always starts. §1.4 says a table over a per-table ceiling is deferred before its first write. Both sentences apply to the first table.

The per-table ceiling wins, including for the first table. "Always starts" applies to the time bound and the byte budget only. The sentence that the per-table byte ceiling is at most the run budget covers the byte budget. It does not cover the fragment, row, Blob, or index ceilings. A first table over one of those is deferred, and the run continues with the next table. That is the progress. A start that ignores the ceiling rewrites an oversized table in the serving process.

3. Deletion files have no recorded size on DataFile

§1.4 measures data files and deletion files with DataFile::file_size_bytes. In Lance 13.0.0 that field is on DataFile only, and it is a CachedFileSize. get() is empty when the size was not recorded. DeletionFile has read_version, id, file_type, num_deleted_rows, and base_id. It has no size field (lance-table format/fragment.rs).

A ceiling written against DeletionFile.file_size_bytes does not compile. A ceiling that skips deletion files does not bound the read. HEAD each deletion object whose size is absent, and stop counting at the ceiling. Keep the data-file rule: use file_size_bytes.get(), and HEAD when it is empty.

4. Admission can wedge on a ghost accepted record

§2.3 says an in-doubt acceptance put can land later as accepted, with no live run, and that the next maintenance write marks it interrupted. §2.4 applies that rule at admission. §3.2 step 4 returns 429 when the active field is set.

If step 4 runs first, the ghost occupies the active slot. Every later POST gets 429. Nothing writes the record. The server stays up, so restart does not clear it.

Resolve an ownerless accepted or running record to interrupted before the active-slot check. Resolve publishing by the history rule in the same read. An ownerless accepted is not a live operation.

5. Cancel of publishing can hide the commit

cancelled means unpublished, and a terminal record is never rewritten. publishing is non-terminal. The cancel route returns 202 for every non-terminal record.

A live cancel after the publication has started leaves the run succeeded (§1.9). A cancel after the run has ended in doubt is different. The manifest request may still land. Writing cancelled then hides that commit.

Leave a publishing record in publishing. Resolve it only by §2.4. cancelled is legal only when no manifest request has been sent.

6. The history match needs the commit ids, on this graph only

commit_id_answers compares one history id with the nonce. history::read_commit looks up a full id. A lookup of the nonce returns nothing. History stores hb1.{block}.{slot}.{nonce}.

Walk parent_commit_id from this graph's main tip back to the input head. Test commit_id_answers on each id. Follow the id with read_commit, which reads an archived block. A scan of the hot buffer misses a commit that the next publication archived. Stop at the input head.

The manifest put is _versions/{read_version + 1}.manifest, and Lance writes it only when that object is absent (omnigraph-catalog commit.rs). Stay publishing while that object is absent. failed with not_committed is legal only when the object exists and no id on the walk answers the nonce.

Only a commit on this graph's main settles this record. A commit on another graph in the cluster does not. A no-op optimize does not move main, so it does not settle the record.

7. The artifact limit runs after the build

persist_graph_index_artifact builds the index, then save serializes the body and writes it (graph_index/persist.rs). §1.8 checks artifact_write_limit on that body. The serving process already holds the index and the body. A body over 512 MiB has already been allocated. MAX_ARTIFACT_BYTES is 4 GiB, and it bounds load, not this build.

Estimate the body from the edge cardinalities before GraphIndex::build. Skip the build when the estimate exceeds the limit. A missing artifact stays safe. Load rejects it and rebuilds in memory.

8. The publication hook has to be optional

commit_updates_on_branch_with_expected is the publication helper for mutate, load, merge, ensure_indices, and optimize. §1.6 says the helper calls before_publish, and that a failed callback publishes nothing.

Say the callback is an optional argument. Optimize sets it. The other callers leave it empty and publish as today. A required callback adds a control-object put to every graph write, or fails that write. Later maintenance kinds pass the same argument. They do not add a second hook.

Optional

  • The configuration clear is an update_config commit on __manifest. cleared, nothing_to_clear, and failed do not cover an ambiguous commit. failed means the keys are still present. Add unknown for the tail. The graph operation can still be succeeded. Today's stat hard-codes committed: false in clear_manifest_auto_cleanup.
  • The motivation says ensure_indices has no production caller. omnigraph index build calls ensure_indices_on_as. It is direct-only (operations.rs, index build) and meets the same cluster lock. The index-build route stays out of scope. The sentence does not.
  • The served path does not read OMNIGRAPH_MAINTENANCE_CONCURRENCY and does not name its planning concurrency. Name the serve setting. Planning stays a read.

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 in the record is a key for that lookup. It is not a second copy of the commit.

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.

The liability to remove before implementation is a terminal state that can hide a commit, a resume key that can skip a table, and a ceiling that can miss deletion bytes or start an oversized table. Cleanup, repair, full-text rebuild, index build, and named-branch optimize should add a kind and a route on this record. They should not add a file, a callback, or a restart rule.

The one owner for "recorded before the manifest request" is an optional argument on commit_updates_on_branch_with_expected. 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 9f76cf29664af24104e6bae5c11faea387672159. gh pr view 1190 returned that oid. The pull request was open. Pull-request reviews and inline review comments were empty. The earlier review and the reply are issue comments on this pull request.

Merge base with origin/main is f76d6011f0479055e1e2c7dffe253ac3ee556d3b. 30af5062 merged that main. origin/main is three documentation commits ahead (2ca84a79, 9d6accc8, b9035d5a). The Rust tree at the head matches f76d6011.

git diff --stat f76d6011 9f76cf29 is five documentation files, +916 / −3. git diff --check on that range is clean. No Rust file changes. This RFC changes no behavior a .gqt case can show, so there is no before/after logic run. A green GQT run would not test the document.

python3 scripts/check-docs.py at this head failed on one pre-existing note: changelog.d/ensure-indices-refreshes-coverage.changed.md is 283 characters, over the 200-character limit for .changed.md. That note came in with #1150. This diff does not touch it. #1193 on current main is the length fix, and it is not in this merge base.

Code and Lance reads at this tree:

  • crates/omnigraph-api-types/src/operations.rs (optimize and index build are direct-only)
  • crates/omnigraph-cli/src/targets.rs, admission.rs, commands/maintenance/mod.rs
  • crates/omnigraph/src/db/omnigraph/optimize.rs (gates, committed: false, artifact call)
  • crates/omnigraph/src/db/omnigraph/table_ops.rs (commit_updates_on_branch_with_expected, plan_index_work_node, ensure_indices)
  • crates/omnigraph-catalog/src/publisher.rs (attempt_commit_id, ExactGraphHead, read-back of the attempt version)
  • crates/omnigraph-catalog/src/commit.rs (zero-retry put of _versions/{read_version + 1}.manifest)
  • crates/omnigraph-core/src/graph_commit_id.rs (commit_id_answers)
  • crates/omnigraph-catalog/src/history.rs (read_commit)
  • crates/omnigraph/src/table_store.rs (can_fold_index, stage_compaction)
  • crates/omnigraph/src/graph_index/persist.rs (MAX_ARTIFACT_BYTES, save)
  • crates/omnigraph-cluster/src/deployment.rs (validate_state) and types.rs (ClusterState denies unknown fields)
  • crates/omnigraph-server/src/lib.rs (ApiError::translated, shutdown grace 25) and deployment.rs (202 until terminal)
  • Lance 13.0.0 dataset/optimize.rs (compact_files can change row ids when stable row ids are off) and lance-table format/fragment.rs (DataFile and DeletionFile)
  • Lance row-id lineage guide: with stable row ids, the logical id survives compaction. The address does not. The RFC rests logical identity on stage_compaction, which matches that split.

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.

Line anchors

These are the lines in docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md at 9f76cf29.

  • Line 246. Blocking. Resume key and start_after.
  • Line 243. Blocking. First table and the per-table ceilings.
  • Line 267. Blocking. Deletion-file size.
  • Line 598. Blocking. Admission order before the active-slot check.
  • Line 571. Blocking. Cancel of publishing.
  • Line 531. Blocking. History walk and the same-graph fence.
  • Line 415. Blocking. Artifact limit after the build.
  • Line 355. Blocking. Optional publication callback.
  • Line 403. Optional. Configuration-clear unknown.
  • Line 67. Optional. ensure_indices has a production caller.
  • Line 236. Optional. Served planning concurrency.

@ragnorc

ragnorc commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Revised in ae4b4b94, after merging main (b6f7de73). I checked each point against the code and Lance 13.0.0 before changing the text. All eight blocking findings hold. Three are fixed differently from the suggestion, and the reasons are given below.

1. Resume key. Fixed with an inclusive key rather than the last non-deferred table. start_after is now start_at: the run starts at that key, or at the first key after it, and wraps to stop before it. The stored position is the first table deferred by gate_hold_limit or byte_budget, so that table is the first the next run visits (with keys A, B, C and a stop before C, the next run starts at C). One key covers both the caller and the server, and a deferred first table needs no special case. A resource_limit deferral defers only its own table, later tables continue, and it does not set the position.

2. First table and ceilings. Adopted. Only the time bound and the run budget never stop the first table. A per-table ceiling defers it like any other table, and the run continues with the next one.

3. Deletion-file size. Confirmed: DeletionFile in lance-table 13.0.0 has read_version, id, file_type, num_deleted_rows and base_id, and no size. Data files use file_size_bytes.get(), with a HEAD request when it is empty. Each deletion file is sized by a HEAD request. The source-fragment ceiling is checked first, so it bounds those requests.

4. Ghost accepted. Adopted. Admission has a new step 3, before the deployment and active-slot checks: a record with no live run is resolved first. accepted and running become interrupted, and publishing follows §2.4. §2.3's acceptance-in-doubt case points to that step.

5. Cancel of publishing. Adopted, in §1.9, the observable contract and the cancel route: a record whose publication is in doubt stays publishing, and cancelled is written only when no manifest request was sent.

6. History match. The walk is adopted: main's parent_commit_id chain from the tip back to the input head, read through history, so read_commit reaches a commit a later publication archived. Under the exact head expectation, only the input head's child can be this run's commit, so the walk tests that one commit with commit_id_answers. The same-graph fence is explicit: a commit on another graph or branch, or a no-op run, does not settle the record.

The settling test differs from the suggestion. It is main's graph head, not the existence of _versions/{read_version + 1}.manifest. A configuration commit, such as the update_config clear in §1.8, takes a __manifest version without a graph commit. The publisher can then retry at the version after it, and that request can still be in flight. Under the object rule, the record would fail while its put could still land. The old text had the same defect, since it compared main's latest __manifest version. With the head rule, not_committed is sound: once the head has moved, every version an attempt could have taken is occupied, and a later attempt would have read the moved head and refused before sending its request.

7. Artifact limit. Adopted, with the bound placed before and during the build rather than estimated only. The manifest's row counts give the fixed-width part of the body exactly: each id's 4-byte length prefix, and the offsets and targets in both directions. The id bytes are not in the manifest, so an estimate from cardinalities alone would not bound them. The build therefore does not start when the fixed-width part already passes artifact_write_limit. Otherwise it counts the serialized bytes as it adds each id and edge, and stops once the count passes the limit. Either way the outcome is skipped(size_limit).

8. Optional callback. Adopted. before_publish is an optional argument of commit_updates_on_branch_with_expected. Its other callers are mutate (exec/mutation.rs), load (loader/mod.rs), merge (exec/merge.rs) and the index writers (table_ops.rs). They pass none and are unchanged: no control-object put and no new failure. The DST cost golden's unchanged rows for those writers are the evidence. A later maintenance kind passes the same argument.

Optional points:

  • The configuration clear now reports unknown beside cleared, nothing_to_clear and failed (keys still present). Neither of the last two fails the graph operation.
  • The motivation now says omnigraph index build reaches ensure_indices_on_as but is direct-only and meets the same lock. Its route stays out of scope.
  • The served path names maintenance_planning_concurrency, a serve setting. Planning only reads.

The evidence table gains rows for:

  • the inclusive resume;
  • the first-table ceiling and deletion files;
  • the head-based settling, with a configuration-only version, another graph and a no-op run each leaving the record publishing;
  • cancellation of an in-doubt record;
  • ownerless-record resolution before the active-slot check;
  • the bounded artifact build;
  • writers that pass no callback.

The decision log has an entry for this revision.

Also, #1175 now has main merged in (de602a32). #1150's index build checked change. It now checks maintain, as the other index writers do, and that change gate never shipped. Workspace Clippy and the engine policy, forbidden-API, schema-apply, maintenance and branching tests pass locally.

ragnorc commented Oct 29, 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 graph history and does not run the work again.

What

This pull request is a design document. Head ae4b4b94 revises the RFC after the review on 9f76cf29. It changes no executable behavior. The Rust in the pull request is the merge of main at b6f7de73.

How

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

Impact

The serving process is the right owner. The eight blocking findings from the review on 9f76cf29 are resolved in the text. Three of those fixes differ from that review, and this review accepts them. Two sentences in the new resolution rule and the artifact rule still disagree with the publisher and with GraphIndex::build. An implementation that follows those sentences can record not_committed without reading the commit, and can allocate the edge scan before the size check. Those sentences must change before an implementation follows them. Pull request #1186 must stay separate. Its head is still 5d5bf01ee04a327a4116c8ae6524a5e1d8bd50f3.

What the revision resolved

Checked against head ae4b4b946b98714aa45103adfa15d07cac2b8eba and Lance 13.0.0.

  • Resume uses inclusive start_at. The stored position is the first table deferred by gate_hold_limit or byte_budget, so the next run visits that table first. Keys A, B, C with a stop before C start the next run at C. A resource_limit deferral leaves the position unchanged, and later tables in the same run continue. The inclusive key is the right key. It covers a deferred first table in one field.
  • Only the time bound and the run budget spare the first table. A per-table ceiling defers the first table, and the run continues.
  • DeletionFile in Lance 13.0.0 has read_version, id, file_type, num_deleted_rows, and base_id. It has no size field. Data files use DataFile::file_size_bytes.get(), and a HEAD when get() is empty. Each deletion file is sized by HEAD. The fragment ceiling is checked first, so it bounds those requests.
  • Admission step 3 resolves an ownerless accepted or running record to interrupted, and a publishing record by §2.4, before the active-slot check.
  • cancelled is written only when no manifest request was sent. A record whose publication is in doubt stays publishing.
  • The walk follows parent_commit_id on this graph's main. read_commit reads an archived block. commit_id_answers is applied to the input head's child. A commit on another graph, a commit on another branch, and a no-op run leave the record publishing. Settlement by the graph head is the right test. Dataset::update_config writes Operation::UpdateConfig and takes a __manifest version without moving the graph head. The publisher checks ExactGraphHead before merge_rows and can retry at the next version while an earlier attempt is still in flight (publisher.rs). A present _versions/{read_version+1}.manifest can be that configuration commit. The graph head commit id is the settlement test.
  • before_publish is an optional argument of commit_updates_on_branch_with_expected. Mutate, load, merge, and the index writers pass none.
  • The configuration clear can report unknown. The motivation names omnigraph index build as a direct-only caller of ensure_indices_on_as. The served path names maintenance_planning_concurrency.

The artifact rule now aims to bound the build before the write. That placement is right. The formula in §1.8 is finding 2 below.

Blocking

1. not_committed must follow a read of the child, and success must store the history id

§2.4 says that when main's graph head has moved, the input head's child is this run's commit when commit_id_answers matches. Otherwise the run is failed with not_committed.

not_committed is sound when that child was read and the nonce does not match. history::read_commit returns Result<Option<GraphLineageRow>>. An error, Ok(None), or a parent chain that never reaches the input head does not show that the commit is absent. A terminal record is never rewritten. Those cases must leave the record publishing.

The success path must store the matched history id. The observable contract says a terminal record holds the graph commit the run published. before_publish runs before attempt_commit_id, which assigns hb1.{block}.{slot}.{nonce} from that attempt's parent and buffer. The outcomes stored at step 3 hold the nonce and the input head. §2.2 lists those fields and does not list the published commit id. succeeded with the step-3 outcomes cannot name the commit. The resolution write must store the child's id.

The sentence that this rule matches the storage-outcomes RFC, and that the branch's next commit settles the record, must name the graph head. A later __manifest version that is only UpdateConfig must leave the record publishing. Waiting for the graph head is later than that RFC's version test, and it is the safe test.

2. The artifact pre-check must match the bytes serialize writes, and the count must precede the allocation

§1.8 says the fixed-width part is exact from the manifest's row counts: each id's 4-byte length prefix, and each edge type's offsets and targets in both directions. The build then counts serialized bytes as it adds each id and edge.

Manifest row counts do not determine that size. GraphIndex::build reads edge src and dst only. The dictionaries are the distinct endpoint strings (graph_index/mod.rs). Offset length is the distinct-endpoint count plus one. The id bytes are the UTF-8 of those strings. The target arrays are the part an edge-row count can bound: one u32 per edge in each direction, 8 bytes per edge. That bound is an upper bound for the targets. It is the targets only.

build calls try_collect on every scan batch before it inserts an id. CsrIndex::build then allocates offsets for every dense node and targets for every edge. A count at today's insert site starts after the scan is already in memory. serialize also writes the magic, a JSON header (MAX_HEADER_BYTES is 16 MiB), and a prelude on every section (persist.rs). Those bytes belong to the object artifact_write_limit covers.

The scan must stop before the next batch is kept. The count must pass the limit before CsrIndex::build allocates. A pre-check may use the edge-row count times 8 bytes as an upper bound for the two target arrays. The text must call that an upper bound. A missing artifact stays safe. Load rejects it and rebuilds the adjacency in memory.

Optional

  • The first decision-log entry still says an in-doubt publication stays non-terminal until main's next version settles it. §2.4 and the second log entry settle it by main's graph head. The first entry must use the graph head, so a later reader does not restore the version test.
  • A HEAD for a data file or a deletion file must use the store and path Lance resolves for that file, including base_id (Dataset::object_store, data_file_dir_for_base, dataset_dir_for_deletion in Lance 13.0.0). A path under the dataset root misses a file the table inherits. §1.4 defers a table whose size cannot be measured, and a resource_limit deferral leaves the resume position unchanged, so that table stays deferred.

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 in the record is the lookup key. The graph head rule removes a false not_committed when the only new version is a configuration commit.

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.

The liability to remove before implementation is a terminal state on an unread walk, and an artifact check that starts after the scan is retained. Cleanup, repair, full-text rebuild, index build, and named-branch optimize should add a kind and a route on this record. They should pass the same optional callback. They should keep this file, this callback, and this restart rule.

The owner for "recorded before the manifest request" is the optional argument on commit_updates_on_branch_with_expected. 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 ae4b4b946b98714aa45103adfa15d07cac2b8eba. gh pr view 1190 returned that oid while the pull request was open. Pull-request reviews and inline review comments were empty. The earlier reviews and the replies are issue comments on this pull request.

Merge base with origin/main is c1a6c83a6d32d46ea5ad6c8b7b0cc35ee74cf5a7. b6f7de73 merged that main. git diff b6f7de73 ae4b4b94 is the RFC file only. git diff --check 9f76cf29 ae4b4b94 is clean. This RFC changes no behavior a .gqt case can show, so there is no before/after logic run. A green GQT run would not test the document.

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

Code and Lance reads at this tree:

  • crates/omnigraph-catalog/src/publisher.rs (ExactGraphHead before merge_rows, attempt_commit_id, the retry loop)
  • crates/omnigraph-catalog/src/history.rs (read_commit)
  • crates/omnigraph-core/src/graph_commit_id.rs (commit_id_answers)
  • crates/omnigraph/src/db/omnigraph/table_ops.rs (commit_updates_on_branch_with_expected)
  • crates/omnigraph/src/graph_index/mod.rs (GraphIndex::build, CsrIndex::build)
  • crates/omnigraph/src/graph_index/persist.rs (serialize, MAX_ARTIFACT_BYTES, MAX_HEADER_BYTES)
  • Lance 13.0.0 lance-table format/fragment.rs (DataFile, DeletionFile) and lance-io CachedFileSize::get
  • Lance 13.0.0 dataset.rs (update_config, object_store, data_file_dir_for_base, dataset_dir_for_deletion)

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. The Lance row-id lineage HTML page returned an empty body. The row-id and compaction claims were checked in the pinned crate (dataset/optimize.rs, and the RFC's stage_compaction rule) rather than on that page.

Line anchors

These are the lines in docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md at ae4b4b946b98714aa45103adfa15d07cac2b8eba. Inline comments could not be posted.

  • Line 571. Blocking. not_committed only after a read of the child, and the resolution write stores the matched history id. read_commit returns Result<Option<GraphLineageRow>>. An error, a missing child, or a chain that never reaches the input head stays publishing. The following sentence must name the graph head.
  • Line 439. Blocking. Manifest row counts do not determine the artifact size. The running count must stop the scan before the next batch is kept, and it must pass the limit before CsrIndex::build. The magic, JSON header, and section preludes count toward the limit.
  • Line 926. Optional. The first decision-log entry must say the graph head.
  • Line 280. Optional. HEAD uses Lance's resolved path, including base_id.

@ragnorc

ragnorc commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Revised in cbb785be, then main merged in f1574536. I checked each point against 71219d16 and Lance 13.0.0 before changing the text. Both blocking findings were right. One fix differs from the review's wording, for the reason given below.

Blocking

1. not_committed follows a read of the child; success stores the history id. §2.4 now ends the walk in one of three ways:

  • The input head's child was read and answers the nonce. The run is succeeded, and the resolution write stores that child's hb1.… id as the published commit.
  • The child was read and does not answer. The run is failed with not_committed.
  • Otherwise the record stays publishing. This covers an error from read_commit, Ok(None) for a link, and a walk that passes the input head without meeting it. A lookup reports where the walk stopped.

The walk now has a stop that does not depend on reaching the input head. Every commit on main is written on main, including a fast-forward merge (docs/dev/merge.md, "Every publishing merge, a fast forward included, writes its own commit"). So graph_manifest_version falls at each step, and the walk stops at the first link not above the input head's recorded version.

§2.2 now lists the published commit's history id. It is written by the terminal write, from the publisher's answer, or by resolution. The sentence on the storage-outcomes RFC now says that this rule is later than that RFC's version test, and that a version that only changes configuration (UpdateConfig) leaves the record publishing. §1.7 and the first decision-log entry name the graph head too.

2. The artifact bound matches serialize, and the count comes before the allocation. §1.8 now says the limit covers the whole object:

  • the magic, format version and header length;
  • the JSON header;
  • every section's prelude;
  • the dictionaries, offsets and targets.

The build reads each edge table's scan one batch at a time instead of calling try_collect. After each batch it adds the exact bytes that batch adds:

  • 4 bytes plus the UTF-8 length for each new distinct endpoint;
  • 4 bytes in every offset array over that endpoint's node type, since TypeIndex is shared by every edge type and direction that ends at the type;
  • 8 bytes per edge.

The header's count and length fields are counted at their widest. The count only grows. Past the limit, the build drops what it holds before it keeps another batch and before CsrIndex::build. A new Memory paragraph states what the build holds:

  • a constant multiple of the counted bytes, beyond one scan batch;
  • each id twice, in the map key and the dense vector;
  • each edge as a pair and then in the CSR and CSC arrays;
  • one copy of the payload in serialize.

A traversal read with no artifact builds the same structure today with no limit.

Where this differs from the review. The pre-check is a lower bound, not an upper bound, because a pre-check that refuses needs a lower bound. An upper bound over the limit does not show that the object is over it. The pre-check reads each pinned edge table's live row count, Dataset::count_rows(None). In Lance 13 that is count_all_rows: physical rows less deleted rows, from fragment metadata. That count is exactly the number of rows the scan returns, so the targets are exactly 8 bytes per edge. The targets plus the framing are then a lower bound on the object, which is what a refusal before the scan needs. The pre-check uses Lance's count, not the manifest pin's row_count, because I did not show that every pin producer stores the live count.

Optional

  • The first decision-log entry now says the graph head.
  • HEAD at the resolved location. §1.4 now sends each HEAD to the store and path that Lance resolves for the file's base_id: Dataset::object_store(base_id) and the manifest's base_paths, the way the collector resolves a borrowed origin (collector.rs). Lance's data_file_dir_for_base and dataset_dir_for_deletion are pub(crate). The text says why this matters: without it, an inherited file is never measured, and the table stays deferred on every run.

Evidence rows

  • One new row: a failed read, an absent link, and a chain that passes the input head leave the record publishing.
  • The nonce row now requires the stored history id.
  • The artifact row now requires a pre-check refusal before any scan, a stop before the next batch and before CsrIndex::build, and a count that is never below the bytes serialize writes.
  • The ceiling row now covers files under another base.

python3 scripts/check-docs.py: Documentation OK (423 Markdown files checked). The PR's diff against origin/main touches only docs/rfcs/.

ragnorc commented Oct 29, 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. Head f1574536 revises the RFC after the review on ae4b4b94. The revision commit is cbb785be. The merge of main adds no RFC text. The pull request changes no executable behavior.

How

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

Impact

The serving process is the right owner. The two blocking findings from the review on ae4b4b94 are partly fixed. The lower-bound refusal is the right test, and this review accepts that argument. Two sentences in the new resolution rule and the offset count still disagree with the catalog and with serialize. An implementation that follows those sentences leaves a published run publishing until the history buffer releases, and it can allocate the CSR arrays after the object has passed the limit. Those sentences must change before an implementation follows them. Pull request #1186 must stay separate. Its head is still 5d5bf01ee04a327a4116c8ae6524a5e1d8bd50f3.

What the revision resolved

Checked against head f157453689fbf9ab9b18444d6a695b475435692d and Lance 13.0.0.

  • not_committed now requires a read of the input head's child that does not answer the nonce. An error, Ok(None), or a walk that passes the input head stays publishing. Success stores the child's hb1 id. That split is the right split. The read it names is finding 1.
  • The first decision-log entry now says the graph head. A later UpdateConfig version leaves the record publishing. That matches Dataset::update_config, which writes Operation::UpdateConfig and leaves the graph head in place.
  • Every publishing merge, a fast-forward included, writes its own commit (docs/dev/merge.md). next_head sets parent_commit_id to the main head this attempt read, and sets graph_manifest_version to dataset.version + 1. Along parent_commit_id, that version falls at each step. The version stop is sound once the walk can read the rows. merged_parent_commit_id is not a step of this walk.
  • The artifact limit covers the whole object serialize writes. The build reads one batch at a time and stops before the next batch and before CsrIndex::build. That placement is right. The offset clause is finding 2.
  • The lower-bound argument holds. A refusal before the scan is sound only when the tested number is at most the object size. An upper bound above the limit does not show that the object is above the limit, so it cannot be the refusal test. This review withdraws that part of the previous finding. GraphIndex::build pushes every scanned row. CsrIndex::build stores one u32 per edge in each direction, so the two target arrays are exactly 8 bytes per edge. Dataset::count_rows(None) calls count_all_rows, which is physical rows minus deleted rows. Scanner::new sets include_deleted_rows to false, so the scan returns that same count. Eight bytes per live edge, plus only bytes that serialize always writes, is a lower bound. TableStore also fills the pin's row_count from count_rows(None). The direct dataset count is still the right source for the scan the build is about to run.
  • One correction inside that claim. The subtraction is not always a metadata read. Fragment::count_rows(None) calls count_deletions. When num_deleted_rows is absent, count_deletions reads the deletion vector. Lance's writer sets num_deleted_rows. The total still matches the scan. This does not change the bound.
  • feat(query): a ranked read reports its ranking #1130 adds RankingOutput on a read result. Optimize does not read or write that field. The publisher, history, the graph index, and optimize are unchanged.
  • feat(cli): add oauth client-credentials profiles #1154 adds an OAuth client-credentials profile on the CLI. The grant in this RFC is still maintain, after the server resolves the bearer token. The profile is another way to obtain that token.

Blocking

1. The resolution read misses the commit it must judge

§2.4 reads the parent chain through history::read_commit. That function reads __history only (history.rs, read_selected). A new graph commit is the branch head row. It enters the commit buffer on the next publish. It reaches __history only when a later publish releases the buffer (CommitBuffer::is_full_under, HISTORY_RELEASE_BYTES = 256 KiB). Until then read_commit returns Ok(None).

§2.4 treats Ok(None) as "neither" and leaves the record publishing. That avoids a false not_committed. It also means the common case never reaches succeeded or not_committed. The same section says the next move of main's graph head settles the record. That move does not archive the child. The summary says the same thing: resolution reads main's history.

The catalog already has one lookup for this. GraphCoordinator::published_record reads the branch head and buffer first (held_record), then __history, then other live branches. ManifestRows::finish restores a null parent_commit_id on a settled_commit. A null parent means the previous buffered commit. #1184 keeps that order. It does not move a buffered commit into __history.

The walk must start at the branch head row. It follows parent_commit_id through the buffer. It calls read_commit only for an id the buffer does not hold. The three endings stay. "The child was read" means this full lookup returned the row. Ok(None) from read_commit alone does not mean the link is absent. "Neither" means the full lookup failed, returned nothing, or the chain passed the input head.

2. The offset count omits arrays serialize writes

§1.8 adds 4 bytes, for each new endpoint, in every offset array over that node type, "for every edge type and direction that ends at its type".

CsrIndex::build sizes the offset array as num_nodes + 1. GraphIndex::build passes type_indices[from_type].len() for the CSR and type_indices[to_type].len() for the CSC. A new id of type T adds 4 bytes to every CSR whose source type is T, and to every CSC whose destination type is T. Both arrays use the full dictionary, including an id that appears on only one side.

"Ends at" names the destination side only. The count can sit below the bytes serialize writes. The evidence row says the count is never below that object. With this clause, CsrIndex::build can allocate after the object has passed the limit. Count every offset array whose width is that type's dictionary.

Optional

  • The HEAD sentence names Dataset::object_store(base_id) and the manifest base_paths, "as the collector resolves a borrowed origin". borrowed_origins returns the base root (BasePath::extract_path). It does not return the file. data_file_dir_for_base is crate-private, and its rule is public data: join data when base_id is absent or BasePath::is_dataset_root is true, and do not join data when the base is data-only. DataFile.path is relative to that directory (the Lance writer stores names such as wide.lance). Deletion names are public in lance_table::io::deletion::relative_deletion_file_path: _deletions/{fragment}-{read_version}-{id}.{suffix}, joined to the dataset root. dataset_dir_for_deletion refuses a base that is not a dataset root. A HEAD of the collector root misses the file. §1.4 already says a miss defers the table on every run.
  • The pre-check says "eight bytes per edge plus the framing" and does not define framing. The counting build counts header count and length fields at their widest. That widest number is an overestimate. It is the safe stop in the counting build, because the build stops when the count passes the limit. It is not a lower bound in the pre-check. A pre-check that adds the widest header, or MAX_HEADER_BYTES (16 MiB), can refuse an object that fits. The pre-check must add only bytes serialize always writes: the 20-byte prefix, the section preludes, one extra u32 per offset array, and the header with those fields at their smallest encoding.

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 in the record is the lookup key. The graph head rule removes a false not_committed when the only new version is a configuration commit.

The lower-bound pre-check removes a scan whose target arrays alone cannot fit. The counting build is what bounds the dictionary bytes, which the manifest cannot know. 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.

The liability to remove before implementation is a resolution read that cannot see a buffered commit, and an offset count that can sit below the object. Cleanup, repair, full-text rebuild, index build, and named-branch optimize should add a kind and a route on this record. They should pass the same optional callback. They should call the same commit lookup. They should not add a file, a callback, or a second walk.

The owner for "recorded before the manifest request" is the optional argument on commit_updates_on_branch_with_expected. The owner for "read this commit, buffered or archived" is published_record. The operation record remains the one server object for maintenance kinds.

Validation

Reviewed head f157453689fbf9ab9b18444d6a695b475435692d. gh pr view 1190 returned that oid. The pull request was open. Pull-request reviews and inline review comments were empty. The earlier reviews and the replies are issue comments on this pull request.

Merge base with origin/main is 71219d16acbd3a908034e9e0510b549d757c21db, which is origin/main. git diff --stat origin/main...HEAD is five documentation files, +1042 / −3. git diff --check on that range is clean. git diff ae4b4b94 cbb785be is the RFC file only, +97 / −36. The commits on main between ae4b4b94 and this head are #1154 (ea895066) and #1130 (71219d16). Neither edits the code this RFC cites.

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.

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

Code and Lance reads at this tree:

  • crates/omnigraph-catalog/src/history.rs (read_commit, read_selected)
  • crates/omnigraph-catalog/src/state.rs (CommitBuffer, parent restore in ManifestRows::finish)
  • crates/omnigraph-catalog/src/lib.rs (HISTORY_RELEASE_BYTES)
  • crates/omnigraph-catalog/src/publisher.rs (next_head, new_manifest_version)
  • crates/omnigraph/src/db/graph_coordinator.rs (published_record, resolve_commit)
  • crates/omnigraph/src/graph_index/mod.rs (GraphIndex::build, CsrIndex::build)
  • crates/omnigraph/src/graph_index/persist.rs (serialize, push_prelude)
  • crates/omnigraph/src/table_store.rs (count_rows into the pin, stage_compaction)
  • Lance 13.0.0 dataset.rs (count_rows, count_all_rows, object_store, data_file_dir_for_base, dataset_dir_for_deletion, update_config)
  • Lance 13.0.0 dataset/fragment.rs (Fragment::count_rows, count_deletions) and dataset/scanner.rs (include_deleted_rows: false)
  • lance-table 13.0.0 format/fragment.rs (DataFile, DeletionFile) and io/deletion.rs (relative_deletion_file_path, public)
  • lance-io 13.0.0 CachedFileSize::get
  • Lance row-id page: with stable row ids, the logical id survives compaction. The address does not. The RFC rests logical identity on stage_compaction, which matches that split.

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.

Line anchors

These are the lines in docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md at f157453689fbf9ab9b18444d6a695b475435692d.

  • Line 598. Blocking. The walk must read the branch head and the commit buffer before read_commit.
  • Line 458. Blocking. Count CSR offsets for the source type and CSC offsets for the destination type.
  • Line 280. Optional. HEAD joins data or _deletions. The collector root is not the file.
  • Line 453. Optional. The pre-check framing must be a lower bound. Widest header fields stay in the counting build.

Inline comments that the 403 prevented:

  • docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md:598 Blocking. history::read_commit reads __history only. The child of an in-doubt publish is the branch head row, then a settled_commit, until a later publish releases the buffer (HISTORY_RELEASE_BYTES, 256 KiB). Until then this call returns Ok(None), and the record stays publishing. The next graph-head move does not archive that child. Start at the head row. Follow parent_commit_id through the buffer. A null parent on a settled row means the previous buffered commit (ManifestRows::finish). Call read_commit only for an id the buffer does not hold. GraphCoordinator::published_record is that lookup. The three endings stay. "The child was read" means that full lookup returned the row.
  • docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md:458 Blocking. CsrIndex::build sizes offsets as num_nodes + 1. GraphIndex::build passes type_indices[from_type].len() for the CSR and type_indices[to_type].len() for the CSC. A new id of type T adds 4 bytes to every CSR whose source type is T and every CSC whose destination type is T. "Ends at its type" names the destination side only. The count can sit below the bytes serialize writes, so CsrIndex::build can allocate after the object has passed the limit. Count every offset array whose width is that type's dictionary.
  • docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md:280 Optional. borrowed_origins returns the base root (BasePath::extract_path), not the file. Join data when base_id is absent or BasePath::is_dataset_root is true. Do not join data for a data-only base. DataFile.path is relative to that directory. Deletion names are public: lance_table::io::deletion::relative_deletion_file_path (_deletions/{fragment}-{read_version}-{id}.{suffix}), joined to the dataset root. A HEAD of the collector root misses the file, and §1.4 then defers the table on every run.
  • docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md:453 Optional. "The framing" is not defined. The widest header count is a safe stop in the counting build. It is not a lower bound. A pre-check that adds the widest header, or MAX_HEADER_BYTES (16 MiB), can refuse an object that fits. Add only bytes serialize always writes: the 20-byte prefix, the section preludes, one extra u32 per offset array, and the header fields at their smallest encoding.

@ragnorc

ragnorc commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Revised in 96c85e0c, then main merged in d1e0fa33. I checked each point against 71c6edce and Lance 13.0.0 before I changed the text. Both blocking findings were right, and both optional ones are taken as written.

Blocking

1. The walk looks up each commit through published_record, not read_commit. I confirmed the problem in the code:

  • read_commit goes through read_lineage_of, which reads __history only.
  • held_record reads the head row and the commit buffer.
  • HISTORY_RELEASE_BYTES is 256 KiB.
  • So the commit this rule must judge is invisible to read_commit until a later publish releases the buffer.

In §2.4, the walk now starts at the head row of a fresh read of main's __manifest. It looks up each parent with GraphCoordinator::published_record. That lookup reads the head and the buffer first with no request, then __history, then the live branches' latest versions. ManifestRows::finish restores the buffer's null parents (state.rs, the index - 1 restore), so the walk sees every link.

The three endings now name the full lookup:

  • "the lookup returned the child";
  • "published_record returns nothing for a link";
  • a sentence saying that Ok(None) from read_commit alone does not show a link is absent.

The cost sentence now says that a buffered commit costs no request and a released one costs one __history read. The Sources of truth row says what "main's history" means: the head row, the buffer and __history, read through published_record. The summary's and contract's uses of "main's history" therefore mean that whole set.

2. Both offset sides are counted. A new id of type T now adds 4 bytes in every CSR whose source type is T and in every CSC whose destination type is T. The text says why, from the code: CsrIndex::build sizes offsets as num_nodes + 1, and GraphIndex::build passes the full dictionary of from_type for the CSR and of to_type for the CSC. So an id seen on one side only still widens both kinds of array over its type. The evidence row now requires edge types whose source and destination types differ, ids seen on one side only, and a pre-check that is never above the written object.

Optional

  • HEAD names the file. §1.4 now states Lance's rule:

    • A data file is DataFile.path under the dataset root joined with data when base_id is absent.
    • When the base is_dataset_root, it is under the base's extract_path joined with data.
    • When the base holds data only, it is under the base's path alone.
    • A deletion file is relative_deletion_file_path under the dataset root or its base, which must be a dataset root.
    • The text says that the collector's borrowed-origin root is not the file.

    I checked data_file_dir_for_base, dataset_dir_for_deletion and the public relative_deletion_file_path in Lance 13.0.0.

  • Pre-check framing. The pre-check adds to 8 bytes per edge only bytes serialize always writes: the 20-byte prefix, the preludes, one extra u32 per offset array, and the header with its count and length fields at their shortest. The text says the widest header is a safe stop for the counting build but not a lower bound, so the pre-check never adds it or MAX_HEADER_BYTES. The deletion-vector correction is in too: count_rows(None) reads the vector when a fragment records no deleted count.

The decision log has a fourth entry. python3 scripts/check-docs.py: Documentation OK (427 Markdown files checked). The PR's diff against origin/main touches only docs/rfcs/.

ragnorc commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: ready.

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. Head d1e0fa33 revises the RFC after the review on f1574536. The revision commit is 96c85e0c. The merge of main is 71c6edce (#1198, TOON read output). That merge does not change the code this RFC cites. The pull request changes no executable behavior.

How

Acceptance is a conditional put of the operation record. The run holds the writer gates, works one table at a time, and publishes the finished tables once. The record stores the intent nonce before any manifest request. On restart, and in the live process, a publishing record stays non-terminal until main's graph head settles it. The walk reads that commit through GraphCoordinator::published_record.

Impact

The serving process is the right owner. Both blocking findings from the review on f1574536 are resolved in the text, and they match the code. Both optional findings from that review are resolved, and they match Lance 13.0.0 and the collector. One new cost sentence does not match the history reader. The lookup the walk must call is still the right lookup. Pull request #1186 must stay separate. Its head is still 5d5bf01ee04a327a4116c8ae6524a5e1d8bd50f3.

What the revision resolved

Checked against head d1e0fa3301e6e7378bdf449c79d8acdba8457251 and Lance 13.0.0.

  • The walk starts at main's head row and looks up each parent with published_record. That function reads held_record first. held_record is the head row or a buffered commit, and it sends no request. It then calls history::read_record, which reads __history only. It then scans the latest version of the live branches. read_commit uses the same __history read and does not see the head or the buffer. A new commit is the head row. It stays in the buffer until a later publish releases it. HISTORY_RELEASE_BYTES is 256 KiB. The three endings now follow that full lookup. Ok(None) from read_commit alone does not mean the link is absent.
  • A null parent_commit_id on a settled row means the previous buffered commit. push_settled writes that null only when the previous buffered commit exists. ManifestRows::finish restores it for every buffered commit after the oldest. The oldest buffered commit stores its parent in full, because it has no previous buffered commit. The head row stores its parent in full (push, not push_settled). The walk sees every parent.
  • A commit on another branch does not move main's graph head, so it does not start this walk. The same-graph fence still holds.
  • A new id of type T adds 4 bytes in every CSR whose source type is T and in every CSC whose destination type is T. CsrIndex::build sizes offsets as num_nodes + 1. GraphIndex::build passes type_indices[from_type].len() for the CSR and type_indices[to_type].len() for the CSC. One TypeIndex holds the type. An id seen on one side still increases that length, so both kinds of array grow. The two target arrays are one u32 per edge in each direction, 8 bytes per edge.
  • The pre-check adds, on top of those 8 bytes per edge, only bytes serialize always writes. The prefix is 20 bytes: magic OGCSRIDX (8), format u32 (4), header length u64 (8). Each section prelude is included. One extra u32 per offset array is the leading 0. The per-node offset entries stay out of the pre-check, so the sum stays at or below the object. The widest header stays in the counting build. Dataset::count_rows(None) subtracts count_deletions. When num_deleted_rows is absent, count_deletions reads the deletion vector. A scan leaves include_deleted_rows false, so that count is the scan's row count.
  • A HEAD uses Dataset::object_store(base_id) and the file path. A data file is DataFile.path under data when base_id is absent or the base is_dataset_root, and under the base path alone when the base holds data only. A deletion file is relative_deletion_file_path (_deletions/{fragment}-{read_version}-{id}.{suffix}) under the dataset root. dataset_dir_for_deletion returns an error when the base is not a dataset root. The Lance layout page describes a non-root base as a directory with no extra subdirectory. The RFC follows the crate, which is the reader. A size that cannot be measured already defers the table. borrowed_origins returns BasePath::extract_path, the base root. capture_dataset_paths joins the file: probe_directory plus deletion_file_path. The prose matches that join.

Optional

1. A released commit is not one read of all.lance

§2.4 says a commit still in the buffer costs no request, and a released commit costs one __history read. The buffer half is right when published_record runs on the coordinator that holds the fresh manifest. held_record returns before any request.

The released half covers only a closed block written as one object. settle_closed names blocks/{block}/all.lance only when the block is closed and the object fits in 64 MiB (MAX_OBJECT_BYTES). A block closes when a later commit of the same branch sits in the next block. A block has 16384 slots (HISTORY_BLOCK_SLOTS). The buffer releases at 256 KiB, so an ordinary release writes an open block as blocks/{block}/{start}-{end}.lance.

read_record gets all.lance first. A missing object is NotFound. The reader then lists that block and reads the range extent. The commit is found. The cost is a missing GET, a list, and a GET.

Keep the call to published_record. A single GET of all.lance misses an open block. The record would stay publishing until a whole-block object exists. The evidence row should say that a released commit resolves through __history. It should not pin one GET.

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 in the record is the lookup key. The graph head rule removes a false not_committed when the only new version is a configuration commit.

The lower-bound pre-check removes a scan whose target arrays alone cannot fit. The counting build is what bounds the dictionary bytes, which the manifest cannot know. Both offset sides belong in that count. 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.

The two liabilities from the review on f1574536 are gone. The walk can see a commit the buffer still holds. The offset count covers the source side and the destination side. The new cost sentence can add a wrong cost test. It does not add a second lookup.

Cleanup, repair, full-text rebuild, index build, and named-branch optimize should add a kind and a route on this record. They should pass the same optional callback. They should call published_record. They should not add a file, a callback, or a second walk.

The owner for "recorded before the manifest request" is the optional argument on commit_updates_on_branch_with_expected. The owner for "read this commit, buffered or archived" is published_record. Open #1184 (b5916cbc) keeps held_record first, then a hinted branch, then __history, then the live branches. A buffered commit on this coordinator still costs no request. The walk should keep calling that function. It should not copy today's order into a second function. The file HEAD already has an owner in capture_dataset_paths. The ceiling should call that resolution. A second copy of data_file_dir_for_base can drift from the collector. 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 d1e0fa3301e6e7378bdf449c79d8acdba8457251. gh pr view 1190 returned that oid. The pull request was open. Pull-request reviews and inline review comments were empty. The earlier reviews and the replies are issue comments on this pull request. The latest of those, before this review, is the author's reply 6487988254 on 96c85e0c and d1e0fa33.

Merge base with origin/main is 71c6edced5034ca3bc1a9ae3d484383067e9bb7f. git diff --stat f1574536 96c85e0c is the RFC file only, +63 / −31. git diff --stat 96c85e0c d1e0fa33 is the #1198 TOON merge: CLI read formatting, user docs, and the skill text. It does not touch the catalog, the graph index, optimize, or operations.rs. git diff --check f1574536 d1e0fa33 is clean.

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 new sentences make claims about current code. Those claims were checked by reading that code, at this head.

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

Code and Lance reads at this tree:

  • crates/omnigraph/src/db/graph_coordinator.rs (published_record)
  • crates/omnigraph-catalog/src/lib.rs (held_record, HISTORY_RELEASE_BYTES)
  • crates/omnigraph-catalog/src/state.rs (ManifestRows::finish)
  • crates/omnigraph-catalog/src/row.rs (push_settled)
  • crates/omnigraph-catalog/src/history.rs (read_commit, read_record, read_selected, settle_closed, ExtentKey)
  • crates/omnigraph-core/src/graph_commit_id.rs (HISTORY_BLOCK_SLOTS, commit_id_answers)
  • crates/omnigraph/src/graph_index/mod.rs (GraphIndex::build, CsrIndex::build, TypeIndex)
  • crates/omnigraph/src/graph_index/persist.rs (serialize, MAGIC, push_prelude)
  • crates/omnigraph/src/db/omnigraph/collector.rs (capture_dataset_paths, probe_directory, borrowed_origins)
  • Lance 13.0.0 dataset.rs (data_file_dir_for_base, dataset_dir_for_deletion, object_store, count_rows) and dataset/fragment.rs (count_deletions) and dataset/scanner.rs (include_deleted_rows: false)
  • lance-table 13.0.0 format/fragment.rs (DataFile, DeletionFile) and io/deletion.rs (relative_deletion_file_path)
  • Lance layout page, https://lance.org/format/table/layout/ : data/, _deletions/{fragment_id}-{read_version}-{id}.{extension}, and the is_dataset_root rule. The deletion reader in the crate is stricter than that page for a base that is not a dataset root.

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.

Line anchors

These are the lines in docs/rfcs/2026-10-29-remote-optimize-through-the-serving-owner.md at d1e0fa3301e6e7378bdf449c79d8acdba8457251.

  • Line 642. Optional. A released commit in an open block is blocks/{block}/{start}-{end}.lance. read_record gets all.lance, treats NotFound as absent, then lists the block and reads the range. Keep published_record. Do not pin one GET in the evidence row.

@ragnorc

ragnorc commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. I took the optional finding and the path-resolution note in 19190bb1. Main has not moved since 71c6edce, so no merge was needed.

  • Resolution cost (§2.4). The sentence no longer claims one __history read. A commit in the buffer still costs no request. A released commit resolves through __history, and its cost depends on its block:

    • one GET of blocks/{block}/all.lance for a closed block written as one object;
    • otherwise that GET's NotFound, a list of the block, and a GET of the range extent.

    An ordinary buffer release writes an open block as blocks/{block}/{start}-{end}.lance. I checked this in read_selected: it tries ExtentKey::Full first, a NotFound reads as absent, and it then calls list_extents for the covering slots. The evidence row already says "resolves through __history" and pins no request count, so it is unchanged. The walk still calls published_record.

  • Sizing paths (§1.4). The row now names the existing owner. The collector's probe_directory applies Lance's base and dataset-root rule, and capture_dataset_paths joins each data and deletion file to it. The ceiling calls that resolution instead of a second copy of data_file_dir_for_base.

The decision log has a fifth entry. python3 scripts/check-docs.py: Documentation OK (427 Markdown files checked). The PR still changes only docs/rfcs/. The RFC stays status: draft, since merging does not accept it.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant