Skip to content

rfc: served graph maintenance - #1168

Open
ragnorc wants to merge 3 commits into
ModernRelay:mainfrom
ragnorc:rfc/served-graph-maintenance
Open

ragnorc wants to merge 3 commits into
ModernRelay:mainfrom
ragnorc:rfc/served-graph-maintenance

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

docs/rfcs/2026-10-26-served-graph-maintenance.md. A graph served from S3 cannot be maintained on 0.14: optimize, cleanup, the full-text rebuild and repair are direct-only, the direct CLI accepts only local storage since #1115, and a direct run beside a running server refuses on the cluster lock (#1082). Moving today's code into the server would not be enough, because optimize holds every main-branch gate for its whole run and refuses on any main publication, and cleanup holds every writer gate on every branch. #646 asks for maintenance through the owning server, and #958 for reads that survive cleanup.

The draft makes the server that holds the cluster lock the only maintenance authority for a served root, with asynchronous graph-scoped routes, CLI dispatch to them and MCP tools; standalone graphs keep the direct CLI. Optimize becomes per-table units that stage without gates and publish in batches, one graph commit each. The guard that keeps this correct is unchanged in strength: each batch publishes with the exact-head compare-and-swap, the head now read under the gate, plus the exact pin of every unit's table, so a rewrite staged on an older pin can never land and resurrect rows. Writers retry over maintenance commits instead of failing; cleanup gains a same-process pin registry, a declared retention floor and, in Phase B, per-table monotone validation instead of the global barrier; maintenance gets maintain and reclaim grants, a commit marker at the next storage stamp, and a reconciler, off by default, in Phase C. The RFC records the approved decisions O1 to O8. It adds dated pending-amendment entries to graph access and cross-process writes, RFC 0011 Decision 11 and server runtime and online deployment, quoting the sentences it would supersede; their bodies are unchanged, because a draft cannot retire an accepted contract. The draft RFC 0046, the invariants page and the write-path guide are listed under documents to update on implementation and are not edited, since behavior has not changed.

Every file:line in the RFC was re-checked at da1c4636; the spec it comes from was written at 7711e4a3, and the drifted references (the operation inventory, the load re-prepare bound, plan_table_compaction, stage_index_fold, the hand-built Rewrite, the deny-list entry) now carry current lines. Lance 13 references were read in the crates.io 13.0.0 sources. The documentation checks pass locally. No code, openapi.json or cluster.yaml shape changes in this pull request.

For review: the decision to challenge is A6, that a maintenance unit's authority is its table's registration and pin plus the schema identity, without the graph head; it would be wrong if any content-preserving effect read another table's state, and the adjacency rebuild is kept out of units for that reason. Re-checking against main changed six details from the spec, each worth a second look: the marker is maintenance_run_id, because GraphCommit.kind already records publication state; the chain walk is the collector's discover_published, since walk_chain no longer exists; the starvation backstop reserves its table before writers take any gate, because holding a table gate and then waiting for the branch gate would invert the gate order; the pin registry replaces the export exclusion for optimize and cleanup only, because branch create and delete take it too; a reclaimed historical version answers 410 version_reclaimed where the server answers 500 internal today, a status change on an existing failure; and a standalone graph takes retention from flags, since it has no declaration file. The storage_hygiene field in the cluster.yaml block is my concrete form of the spec's "where storage hygiene is configured", since serving credentials cannot in general read lifecycle rules. Least certain: the Lance 14 facts (BTREE becoming row-address based, the distributed index optimize) come from the 14.0.0-rc.2 notes and pull requests, not a stable source, and TransactionRebase over a detached chain is unexecuted, which is why Phase B's rebase is gated on a Lance fence guard.

Propose that a served root's optimize, cleanup, full-text rebuild and
repair run in the server holding the cluster lock, as owned operations
behind asynchronous graph-scoped routes. Optimize is cut into per-table
units that stage without gates and publish in batches under an exact
pin compare-and-swap; writers retry over maintenance commits; cleanup
protects same-process reads and gains a declared retention floor.

Record pending amendments in the decision logs of the three accepted
RFCs whose sentences the draft would supersede, and add the registry
row.

ragnorc commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: revise.

Why, what, how, impact

A graph served from S3 cannot be maintained on 0.14. Optimize, cleanup, the full-text rebuild, and repair run only in a direct process. That process accepts local storage only. A direct process beside a live server stops on the cluster lock.

This RFC gives that work to the server that holds the cluster lock. It cuts optimize into per-table units, stages them with no writer gates, and publishes them in short batches. Each batch is one graph commit. Writers retry when the new commits are maintenance commits. Cleanup keeps a retention floor and, later, stops taking every writer gate.

The impact is that foreground writes can continue during maintenance, and an S3 graph can be compacted without a second publisher. The publication rule in Phase A is fit to accept. Six points in the text are not. They change row survival, write success, or which open design owns indexes.

What I checked

I read this RFC at 6d31796ab1ef3da102d22a579e699bcc4d5c05be and the engine at base da1c46368c33876d61615add0ceb6778f9ce2b58. I read Lance 13.0.0 from the crates.io sources, the Lance transaction page, and the Lance distributed indexing guide. I compared open PRs #1150, #1156, and #1171, and issues #1162, #1163, and #1164.

These premises hold:

  • The four commands are CLI-only (crates/omnigraph-api-types/src/operations.rs). A direct target is local (crates/omnigraph-cli/src/operator_routing.rs, crates/omnigraph-cli/src/scope.rs). The 0.14 upgrade note says direct maintenance has no remote path.
  • A direct write takes cluster admission and reports state_lock_held when the server owns the lock (crates/omnigraph-cli/src/admission.rs).
  • Optimize holds the shared schema gate, the main branch gate, and every main table gate from planning through the one publish (crates/omnigraph/src/db/omnigraph/optimize.rs). The branch gate still covers the adjacency rebuild. Schema and table gates drop before that rebuild.
  • Any main publication moves the graph head. Optimize publishes with that captured head (crates/omnigraph/src/db/omnigraph/table_ops.rs). Same-process writers block, then fail their own revalidation.
  • Loads re-prepare at most 32 times (crates/omnigraph/src/loader/mod.rs). Strict read-modify-write returns ReadSetChanged.
  • Cleanup holds every branch gate and every table gate for the trace (optimize.rs). The collector then refuses a moved head, branch, or tag (crates/omnigraph/src/db/omnigraph/collector.rs).
  • A reclaimed historical read is HTTP 500 internal (crates/omnigraph-server/src/lib.rs). The change feed already uses 410 change_feed_gap.
  • optimize, cleanup, ensure_indices, and repair take no actor. repair with confirm publishes as omnigraph:repair (crates/omnigraph/src/db/omnigraph/repair.rs).
  • A detached Lance commit skips rebase (lance-13.0.0/src/io/commit.rs). Stock cleanup_old_versions does not trace detached manifests and is deny-listed.
  • Default lease is 30 seconds, so the owner fence is 20 seconds (crates/omnigraph-cluster/src/lease.rs). Shutdown grace is 25 seconds. Deployment drain is 300 seconds. A beat is lease/3, and the keeper deletes the beat two sequences back.
  • The three accepted sentences in F20 are where the RFC cites them.

A6 is the right rule for Phase A. The unit effect is one table pin plus the schema identity. The batch still goes through one __manifest publication. The head used in ExactGraphHead is the head read under the gates. A moved pin drops that unit. That keeps one publication door, one attempt, and unreachable staging. The adjacency rebuild reads every edge table, so it stays outside the unit. Its per-edge stamps already reject a stale artifact (crates/omnigraph/src/graph_index/persist.rs).

The pending decision-log entries are the right way to amend accepted RFCs. They do not pretend a draft has retired those sentences.

Blocking

  1. The coexistence promise is stronger than the mechanism. M2 says a write never fails because of maintenance. The writer section says the same. The cap is 3 retries. Batches are 5 seconds apart, so a long load can see more than 3 maintenance commits. The in-memory map drops entries, and a missing entry uses today's conflict path. Loads already have a 32-attempt re-prepare loop. Use that loop. Retry while every commit in the gap is a maintenance commit. The backstop waits one whole unit, not one publish window. Name that wait as the one exception.

  2. Phase B rebase can hide a concurrent effect. TransactionRebase::check_txn is the attached-path classifier. Detached commit never calls it. For a Rewrite it accepts Project and Append with no further check, and it rejects every Merge. Schema apply is a Project or a Merge. A Delete on a fragment the rewrite did not touch is also accepted, and the current manifest then has deletion vectors the staged rewrite never saw. Apply the rewrite groups onto the current manifest and commit through the existing detached path. Drop Project, Merge, CreateIndex, and any overlapping update until a guard shows the result. The unit is a compaction commit plus a fold, not one Rewrite.

  3. The 20 second unit budget stops the work Phase A has to do. Phase A still rebuilds a lagging index over the whole table. That work follows table size. A dropped-column task is one fragment and cannot be split. budget_exceeded is a deferral, and the reconciler suspends a table only after failures. The backstop then reserves the same table again. Give whole-index folds their own budget, or keep them out of Phase A units. Suspend a table when one task cannot fit the unit budget.

  4. Phase C index maintenance fights PR rfc: incremental index reconciliation #1156. That RFC is the index owner. On Lance 13 a full-text merge does not drop stale entries. A single merged full-text segment can return a row for a term it no longer holds. BTREE can filter during its merge. Vector subset builds do not share centroids. This RFC merges every kind into one segment and re-issues the full-text certificate. Point Phase C at rfc: incremental index reconciliation #1156, and keep the whole rebuild until that guard is green. Uncommitted segment files that no manifest names are orphans. The collector's age rule applies. cleanup_old_versions does not.

  5. index build from PR feat(cli): build a branch's declared indexes with omnigraph index build #1150 is the same hole. That PR adds a direct omnigraph index build and ensure_indices_on_as, which enforces Change. After it merges, an S3 server still cannot build a branch index. Add that command to this operation set, or say here that branch builds stay unavailable on S3 until named-branch optimize. One operation cannot be Change in feat(cli): build a branch's declared indexes with omnigraph index build #1150 and maintain here. Use the one-release overlap already chosen for the full-text rebuild.

  6. Phase B cleanup must keep the tag checks. The cited block re-reads branch heads and then validates tags (collector.rs). Replace only the head-equality refusal. Keep tag validation, unattached snapshots, and merge bases. Delete a file only when no live pin lists it. The chain walk is enough only when every new commit inherits files from its parent or writes new files.

Optional

Tradeoff and liability

Phase A, after the retry rule and the budget are corrected, removes a real liability. An S3 graph can be maintained by the one process that already publishes. Writers no longer stop for a whole optimize. The state that earns its cost is two policy actions, a pin set on the existing read guard, and later one maintenance_run_id on the commit record. Run status then comes from commits. There is no job table.

A hand-built rebase and a second index fold do not earn their cost. Lance already builds the detached manifest. #1156 already owns incremental indexes. The concepts in several places are publication, the pin check, reader protection, and index catch-up. One publisher (the existing exact-head compare-and-swap), one read guard, and one index reconciler make those invariants hold. A later maintenance feature then adds a unit and a route.

What Phase A gives up is one commit per optimize. A run publishes several batches, and physical layout can lag between them. Logical rows must stay the same. That matches invariant 7. The exact pin check stays. I would not give that up to make more units publish.

Lance 14 row-address BTREE is correctly left as a guard. I did not treat the 14.0.0-rc.2 notes as a stable contract. The Lance 13 path is enough: on stable row ids, a row-id index follows its rows and an address index drops coverage of rewritten fragments (lance-table manifest_build.rs).

Validation

  • PR head 6d31796ab1ef3da102d22a579e699bcc4d5c05be, base da1c46368c33876d61615add0ceb6778f9ce2b58. No review and no comment on this head before this review.
  • Local main is da1c46368c33876d61615add0ceb6778f9ce2b58.
  • This PR changes documents only. I did not run GQT or cargo test. A row-level test cannot fail before and pass after, because no execution path changes. The premises above are from reading the cited code and the Lance 13.0.0 sources.
  • I did not run optimize on S3. The 576.8 second planning figure and the 657-table listing estimate stay the author's measurements.
  • I did not execute TransactionRebase on a detached chain. The detached commit function does not call it.

Recommendation

Revise the six blocking points in the text. I would accept the lock-holder placement and the Phase A publication rule after those corrections.

Inline comments

docs/rfcs/2026-10-26-served-graph-maintenance.md:479

The promise in M2 says a write never fails because of maintenance. This sentence says the same. The mechanism can still fail the write.

The cap is 3 retries. Batches are 5 seconds apart, so a load longer than about 15 seconds can see more than 3 maintenance commits. The map drops old entries and entries from before boot. A missing entry uses today's ReadSetChanged path. Loads already re-prepare at most 32 times (crates/omnigraph/src/loader/mod.rs).

Use that existing loop. Retry while every commit between the captured head and the new head is a maintenance commit, and stop at the existing attempt budget. In Phase A, bound the map by the attempt, not by a count the batch pacing can exceed. The backstop (about line 433) waits one whole unit, not one publish window. Name that wait as the one exception, and bound it by the unit time.

docs/rfcs/2026-10-26-served-graph-maintenance.md:416

TransactionRebase::check_txn classifies attached commits. A detached commit never calls it (lance-13.0.0/src/io/commit.rs, the skip at the start of do_commit_detached_transaction). For a Rewrite the classifier accepts Project and Append with no further check, and it returns a retryable error for every Merge (conflict_resolver.rs). Schema apply is a Project or a Merge. A Delete or Update that misses the rewritten fragments is also accepted, so the current manifest has deletion vectors the staged rewrite never saw.

Apply the rewrite groups onto the current manifest. Reassign fragment ids above that manifest's high-water (crates/omnigraph/src/table_store.rs). Commit through the existing detached path so Lance updates index bitmaps (lance-table manifest_build.rs). Drop the unit for Project, Merge, CreateIndex, and any overlapping Update, Delete, or Rewrite until a guard shows the rebuilt manifest.

The shipped unit is a compaction commit plus a fold (optimize.rs, stage_compaction then stage_index_fold). One Rewrite check does not cover that chain. The evidence-table guard must include schema apply and a delete on a fragment the rewrite did not touch. The public Lance transaction page also says new Rewrite fragment ids come from ReserveFragments. The shipped detached path assigns ids above the high-water instead. Keep that rule. Do not add a second manifest builder.

docs/rfcs/2026-10-26-served-graph-maintenance.md:323

This budget is also the backstop hold. Phase A still folds a lagging index by a whole rebuild (TableStore::stage_index_fold). That cost follows table size, so the last unit on a large table cannot finish in 20 seconds. A dropped-column task is one fragment (plan_table_compaction) and cannot be split to meet a byte budget.

budget_exceeded is a deferral. The reconciler suspends a table only after consecutive failures. The backstop then reserves the same table and writers wait out another cancelled unit.

Give the whole-index fold its own budget, or leave folds out of Phase A units until the incremental reconciler exists. Suspend a table when one unsplittable task cannot fit the unit budget. Do not reserve that table again.

docs/rfcs/2026-10-26-served-graph-maintenance.md:493

PR #1156 owns this algorithm, and its rule is different. On Lance 13 the full-text merge does not filter old entries. #1156 cites lance-index inverted.rs passing no old-data filter. A merged full-text segment can return a row for a term it no longer holds (lance#9817, guard index_delta_segment_serves_stale_entries_of_an_updated_row). BTREE can filter in its merge. Vector subset builds train their own centroids, so they do not merge.

This paragraph merges every kind into one segment and re-issues the full-text certificate on that merge. That publishes the stale full-text case. Point Phase C at #1156 and keep the whole rebuild until that RFC's guard is green.

execute_uncommitted files that no manifest names are orphans. The collector age rule applies to them. cleanup_old_versions does not. The Lance distributed indexing guide also requires disjoint fragment coverage in one commit. A detached CreateIndex does not check that. #1156 already puts the check in the reconciler.

docs/rfcs/2026-10-26-served-graph-maintenance.md:264

PR #1150 adds omnigraph index build and ensure_indices_on_as. That entry point enforces Change on the branch, and the command is direct-only. After that PR merges, an S3 server still cannot build a branch index.

Add index build to the rows this paragraph moves onto HTTP, on this lane and these grants, or say in this RFC that branch builds stay unavailable on S3 until Phase C named-branch optimize.

The policy action has to be one action. This RFC gives ensure_indices_as to maintain. #1150 gives the same call to Change. Use the one-release overlap already chosen for the full-text rebuild (O5).

docs/rfcs/2026-10-26-served-graph-maintenance.md:548

The cited range is not only the head check. collector.rs re-reads the branch list and each branch head, then calls tags.validate() and validates each table tag inventory. Replace only the head-equality refusal.

Keep tag validation, unattached-snapshot roots, and merge-base roots. Delete a file only when no live pin lists it. The chain walk proves that only when every new commit inherits files from its parent or writes new files. A pin that is itself a version in the reclaim set skips that table. State that this includes a branch created at an old commit and a tag that names a reclaimed version.

Bound the writer promise by the existing 32-attempt loop, with writer
priority and the backstop's one-unit wait as the named exception.
Replace the TransactionRebase classifier with applying the rewrite
groups onto the current manifest through the detached path, dropping
every link the engine cannot prove. Split folds out of compaction units
into index units with their own budget, suspend tables whose task or
fold cannot fit, and point incremental index work at the incremental
index reconciliation RFC. Add index build to the served operation set
with the one-release grant overlap, keep the collector's tag, branch
and incarnation checks in Phase B, and keep automatic compaction behind
an explicit flag.
@ragnorc

ragnorc commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Revised in 49d1f345. Main had not moved (da1c4636), so nothing was merged in. Every new claim was re-read at that commit or in the crates.io Lance 13.0.0 sources. The decision log's second 2026-10-26 entry names each superseded sentence.

Blocking

  1. Retry contract. Changed. M2 now promises what the mechanism delivers: maintenance costs a write at most one extra attempt and one publish window. The writer section uses the existing 32-attempt loop, which mutations, loads and Blob writes already share (exec/mutation.rs:1082-1122, loader/mod.rs:733-788, exec/blob_write.rs:167-199). A write retries while every commit in the gap is a maintenance commit, for every write kind. This includes updates, deletes and overwrite loads, because a fresh attempt re-runs against the new snapshot and never replays the stale plan, which is the concern behind mutation.rs:1200-1203.

    • The cap of 3 is gone.
    • The Phase A map keeps an entry while any attempt whose captured head precedes it is in flight. It is bounded by the attempts in flight, not by a count.
    • Writer priority is added because a 32-attempt loop alone can still livelock. A load whose attempts outlast the 5 s pacing would be invalidated by every batch. So maintenance invalidates each write at most once: once a write has retried over a maintenance commit, the lane publishes nothing more on that branch until the write finishes.
    • The backstop's wait is one whole unit, bounded by the unit time. It is named as the one exception.
    • Conditional writes (Omnigraph-If-Graph-Commit, never retried, mutation.rs:874-890) keep their exact precondition and fail on a maintenance commit. M2 says so.
  2. Phase B rebase. Changed. TransactionRebase::check_txn is out, and F13 now states why: it is the attached-path classifier, it accepts Project with no check, and it calls every Merge retryable. The engine reads each link's transaction itself.

    • The unit rebases only over Appends and over Deletes or Updates that touch no rewritten fragment.
    • It drops on Project, Merge, CreateIndex, Rewrite, Overwrite and any overlapping update or delete.
    • To rebase, it re-stages the rewrite groups as a new Rewrite with read version P and ids above P's high-water. It commits that on P through commit_staged_detached, so Lance's manifest build splices the groups into the current manifest and keeps P's other deletion vectors. There is no second manifest builder and no ReserveFragments.
    • The guard covers an append, a delete on an untouched fragment, schema apply and an overlapping update. Phase A's drop and re-plan stays until it is green.
    • The RFC now says a table's work is a chain of up to three links (optimize.rs:543-657), not one Rewrite. Only the compaction commit is rebased.
  3. Budgets. I chose to keep folds out of compaction units. The fold and the missing-index build move to index units: the existing index writer (table_ops.rs:227-366), which feat(cli): build a branch's declared indexes with omnigraph index build #1150 exposes as index build.

    • Why this option: on stable row ids, compaction keeps row-id index coverage (F14), so a unit never needs its fold. A whole rebuild cannot fit a budget sized to bound a writer's wait. And optimize and index build then share one index path, which rfc: incremental index reconciliation #1156 can make incremental.
    • Index units have their own budget: a fold time limit plus the memory envelope. They are never reserved by the backstop, and a fold whose pin moved is deferred. The RFC states the Phase A limit: a table written more often than its rebuild takes is reported until rfc: incremental index reconciliation #1156 lands.
    • Compaction units take plan tasks up to the byte budget. Lance allows committing any subset of a plan (optimize.rs:78-82). A single task over the budget, or a unit over its time, suspends the table, and the backstop never reserves a suspended table again.
  4. Phase C index maintenance. Changed. Phase C points at rfc: incremental index reconciliation #1156, and the whole rebuild stays until rfc: incremental index reconciliation #1156's guard is green. The every-kind single-segment merge and the certificate re-issue are removed. Uncommitted execute_uncommitted files are orphans, reclaimed by the collector's seven-day age rule; cleanup_old_versions never runs. The Phase C stale-entry evidence row is gone in favour of rfc: incremental index reconciliation #1156's guards.

  5. index build. Added to the operation set as POST …/maintenance/index-build, on the lane, with change or maintain for one release and then maintain. This is the same overlap as the full-text rebuild, now index-rebuild with kind: full-text after feat(cli): build a branch's declared indexes with omnigraph index build #1150's naming.

    • Why add it rather than leave branch builds unavailable on S3: it is the same index writer the optimize run uses, so serving it adds a route, not a mechanism. Named-branch optimize is conditional, so waiting for it could leave S3 branches unindexed indefinitely.
    • The RFC notes that optimize and index build publish per batch rather than once per run. A1's pending entry now names index build.
  6. Phase B cleanup. Changed. Only the head-version equality in collector.rs:1234-1256 is replaced. The branch-inventory refusal, the incarnation check, tags.validate() and each table's tag inventory stay, and so do the unattached-snapshot and merge-base roots.

    • Each table's sweep re-reads its pins. It skips the table if a pin is itself a version in the reclaim set (a branch created at an old commit), or if a chain link is in it. A tag naming a reclaimed version is refused by tag validation, as today. It deletes only files no live pin lists.
    • The chain walk is stated to be sufficient exactly when each new commit lists only its parent's files or files it wrote. Every engine writer stages a detached child of the pin it read. Pointer switches, old-commit branches and published proposals are existing versions, which the skip catches. A foreign writer is outside the contract.

Optional

ragnorc commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: revise.

Why, what, how, impact

A graph served from S3 cannot be maintained on 0.14. Optimize, cleanup, the full-text rebuild, and repair run only in a direct process. That process accepts local storage only.

This RFC gives that work to the server that holds the cluster lock. Compaction and index catch-up become separate units. Each batch is one graph commit. A write retries inside its existing loop when every new commit is a maintenance commit.

Phase A publication is fit to accept. The exact pin check stays. The six earlier blockers are fixed in the text, except the new writer-priority rule below. PR #1177 still specifies a second contract for the same command.

Earlier findings

1. Retry contract. Partly resolved.

The cap of 3 is gone. The loop is the one mutations, loads, and Blob writes already use (exec/mutation.rs 1082-1122, loader/mod.rs 733-788, exec/blob_write.rs 167-199). Updates and deletes stay outside that retry today, because a replay of the old plan would be a different write (mutation.rs 1200-1203). A fresh attempt runs the statement again. That is the right change. A conditional write returns PreconditionFailed, and the loop does not retry it (mutation.rs 1164-1176). The backstop wait is the one long wait.

Writer priority is new. It is not safe yet. See blocking finding 1.

2. Phase B rebase. Resolved.

Detached commit does not call TransactionRebase::check_txn. The skip is at the start of do_commit_detached_transaction in Lance 13.0.0 io/commit.rs. That classifier accepts Project with no further check and rejects every Merge. Dropping those links is required.

The re-stage rule matches the code. commit_staged_detached builds the manifest from the dataset handle, through build_manifest_prepared(Some(dataset.manifest)). The handle must be the dataset at P. The read_version field does not select the manifest on this path. Lance then replaces fragments by id and keeps every other fragment, with its deletion vector (handle_rewrite_fragments).

New ids have to sit above P's high-water, and they must not be 0. fragments_with_ids assigns an id only when the id is 0. Every other id is kept. Index bitmaps are updated from the ids in the Rewrite groups. Ids above the high-water stay unique and match those bitmaps. This is the same assignment stage_compaction uses (table_store.rs 4359-4365). ReserveFragments is not used. The public Lance transaction page still says to reserve ids first. The pinned 13.0.0 code does not do that for a non-zero id. Follow the code.

3. Budgets. Resolved.

Folds are index units. They are outside the 20 second budget, and the backstop does not reserve them. A compaction task that does not fit the byte budget suspends the table. Lance commits any subset of a plan (dataset/optimize.rs 78-82). plan_table_compaction still refuses source budgets (table_store.rs 4277-4286), so a dropped-column task stays inside the unit budget.

4. Phase C and #1156. Resolved.

The all-kinds single-segment merge and the certificate re-issue are gone. Whole rebuild stays until the #1156 guard is green. On Lance 13, #1156 can filtered-merge a BTREE and still rebuilds full text and vector. This RFC does not publish the stale full-text case.

5. Index build. Resolved.

index build is in the route table. The one-release overlap matches the full-text rebuild. #1150 gates ensure_indices_on_as on Change.

6. Phase B cleanup. Resolved.

collector.rs 1234-1251 refuses a changed branch list and a changed head or incarnation. Tag checks are the next lines, 1252-1256. The text replaces only the head-version equality. Tag checks, unattached snapshots, and merge bases stay. A pin that is itself in the reclaim set skips that table.

Earlier optional notes. Resolved.

F7 matches the full-text Change check (table_ops.rs 82-93) and enforce (omnigraph.rs 1128-1152). Read pins follow the captured snapshot, and the response stream keeps that snapshot (ingress.rs 239-264). The change feed keeps change_feed_gap. The CLI repair command has no --confirm flag. RepairOptions::confirm reaches a publish only when the registration has no last linear version and no staged version (repair.rs 369-387). Foreign drift returns before that path. The maintenance marker does not decide a change-feed row. Off-runtime work does not explain #1164. A stopped cloud cluster stays an open gap. Automatic compaction stays off unless automatic.compaction is true.

Blocking

1. Writer priority and the backstop stop each other

M2 promises one extra attempt. The lane then publishes no further batch on that branch until the write finishes (writer priority, about line 565).

That ban also covers the backstop batch. The backstop reserves the table before the writer takes a gate (about line 484). A write that has already retried then waits on the reservation, so it does not finish. The reserved unit needs a batch publish to release the reservation. Writer priority forbids that publish.

The text does not say that the unit timer runs while the unit waits to publish. If the timer covers that wait, the unit is cancelled and the table is suspended (unit_time_exceeded). The backstop never reserves it again. If the timer does not cover that wait, the branch waits with no further maintenance batch.

Steady writes to that table hit this path. The table is the one the backstop exists to compact. Other tables on the branch also wait, because the ban is per branch.

Writers in general do not livelock. After one retry the lane stops, so a fresh attempt does not see a later maintenance commit. Maintenance still publishes between generations of writes. The failure is this cycle.

A batch that has already taken the publish gates can still commit after the write decides to retry. That commit can force a second retry. M2 allows one.

Required rule:

  • Writer priority blocks ordinary batches only.
  • The backstop publish is the same exception already named for the long wait. It may publish while a write is in its retry.
  • A write that is waiting on the reservation does not hold the ban.
  • The fresh attempt opens its snapshot after that backstop publish. Commits already inside the publish window count as the one retry.

2. PR #1177 is a second contract for the same command

#1177 implements a different served-optimize RFC. It adds the policy action optimize. It keeps today's whole-run gates. It stores a journal at __cluster/maintenance.json. An unresolved journal refuses new maintenance, deployment, and writable admission.

This RFC forbids that journal (A3 and the deny-list). It removes the whole-run gate (F2, M2). #1175 already adds maintain and reclaim for this RFC. Optimize cannot require both optimize and maintain.

Name #1177 in this RFC. State that this design replaces that journal and that action. Otherwise two recovery rules can both merge.

Optional

1. Name the operation fields the rebase reads

A keyed insert is Operation::Update with empty updated_fragments and empty removed_fragment_ids (table_store.rs 3506-3516). That update is compatible. A rule that drops every Update will not rebase the common write.

A delete uses updated_fragments and deleted_fragment_ids (Lance 13.0.0 lance-table operation.rs 46-49). It has no removed_fragment_ids. An overlapping delete is the resurrection case. handle_rewrite_fragments replaces the old fragment, so P's deletion vector on that id is discarded.

The guard row tests an untouched delete and an overlapping update. Add an overlapping delete. The rebased manifest must not contain the deleted rows.

2. The Phase A map bound is wider than the sentence

Priority starts after the retry. Before that retry, one long attempt can see a batch every 5 seconds. Each batch adds a map entry. The entry stays until the attempt ends. The map is bounded by pacing times the attempt duration. It is not bounded by the number of attempts. Entries are kept, so a missing entry does not fail the write. Correct the sentence.

3. Publish the version commit_staged_detached returns

That function can chain an untrained full-text CreateIndex on the Rewrite (table_store.rs 4061-4068). The returned version is that tip. A rebased unit that publishes the Rewrite version leaves the declaration unreachable.

Tradeoff and liability

Phase A still removes a real liability. One process publishes. Writers do not stop for a whole optimize. The state that earns its cost is two policy actions, a pin set on the existing read guard, and later one maintenance_run_id. There is no job table.

Writer priority is the new liability. It is a second wait, shared by every batch on the branch. It is justified only if the backstop can still publish. Lance builds the manifest. #1156 remains the index owner.

What Phase A gives up is one commit per optimize. Logical rows stay the same. The exact pin check stays.

The #1177 journal would add a second durable record and a second recovery path. This RFC is the smaller contract if it is the only one.

Validation

Recommendation

Revise blocking findings 1 and 2. I would accept the lock-holder placement, the Phase A pin rule, and the revised rebase after the backstop cycle is closed and #1177 is named.

Inline comments

docs/rfcs/2026-10-26-served-graph-maintenance.md:569

This ban also covers the backstop batch. The backstop (about line 484) reserves the table before the writer takes a gate. A write that has already retried then waits on that reservation, so it does not finish. The reserved unit needs a batch publish to release the reservation. Writer priority forbids that publish.

If the unit timer covers the publish wait, the unit is cancelled and the table is suspended. The backstop never reserves it again. If the timer does not cover that wait, no further maintenance batch on the branch can start.

Let writer priority block ordinary batches only. The backstop publish is already the one long wait in M2. It must be allowed to publish. A write that is only waiting on the reservation must not hold the ban. Count a batch that has already taken the publish gates as part of the one retry, so M2 stays one extra attempt.

docs/rfcs/2026-10-26-served-graph-maintenance.md:907

PR #1177 is already implementing a different record for this command. It stores __cluster/maintenance.json, adds the policy action optimize, keeps the whole-run gates, and refuses writable admission while the journal is unresolved.

This RFC forbids that journal. #1175 adds maintain and reclaim for this text. Name #1177 here and state that this design replaces that journal and that action.

docs/rfcs/2026-10-26-served-graph-maintenance.md:480

Add an overlapping delete to this guard. A delete stores the touched ids in updated_fragments and deleted_fragment_ids, not in removed_fragment_ids. handle_rewrite_fragments replaces the old fragment, so a missed overlap drops P's deletion vector and the deleted rows return.

Also say that a keyed insert is an Update with both of those lists empty (table_store.rs 3506-3516). That update is compatible. Dropping every Update skips the common write.

docs/rfcs/2026-10-26-served-graph-maintenance.md:573

Priority starts only after the retry. Before that retry, one long attempt can meet a new batch every 5 seconds, and each batch adds an entry. The map is bounded by pacing times that attempt's duration. It is not bounded by the number of attempts. Entries are kept, so correct the bound sentence. The failure mode stays conservative.

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