Repository navigation
Optimizing a served cluster requires stopping the server, and each direct run keeps its lock #1082
Description
Problem
On a cluster with a running server, optimize (and cleanup, repair, rebuild-full-text-indexes) can only run after the server is stopped. Each completed run then keeps the cluster lock, so the next graph cannot start until an operator force-unlocks it. Compacting every graph of a served cluster therefore needs an outage plus one exact-ID unlock per graph.
The path at 0a62bb7:
- The direct commands take the cluster lock. Every direct maintenance command calls
admission::ensure_graphbefore it opens the graph (maintenance/mod.rs#L70, and L120, L145, L218). For a cluster graph, that call acquires the cluster's exclusive admission (admission.rs#L63). Since Build durable offline deployments and exact recovery #849 (0.12), that is the same lock the server holds for its whole lifetime, so the command refuses withstate_lock_heldwhile the server runs. - The server cannot run it either. The server has no maintenance route. The maintenance docs say these commands "do not run through the HTTP server" (maintenance.md#L11). server: coordinated optimize and cleanup operations with resumable status #646 tracks a served route.
- A completed command keeps its lock. A command that succeeds deliberately keeps its own lock: "command completion alone does not release this lock" (admission.rs#L71). The next direct command, for example the next graph's optimize, refuses until
cluster force-unlock <id>. - The exclusion comes from admission, not from optimize. The engine's optimize is safe beside live writers. A write that lands on a table while its compaction is staged fails the run with a read-set conflict (maintenance.md#L51). The same page does not say that on a served cluster this guarantee is unreachable, because the command cannot start beside the server.
Evidence
A local cluster with two graphs on 0.14.0-edge+0a62bb7, starting after cluster apply, which keeps its own lock:
optimize --cluster R --graph a → [state_lock_held] … lock id 01M58B44SH… (the apply's lock)
cluster force-unlock 01M58B44SH… --cluster R
optimize --cluster R --graph a → ok; stderr: cluster admission retained: … lock_id=01M58B4AF2…
cluster status --cluster R → lock_id 01M58B4AF2…
optimize --cluster R --graph b → [state_lock_held] … lock id 01M58B4AF2…
A production deployment with six graphs on an S3-compatible bucket now runs compaction as a scheduled outage. It stops the server, optimizes each graph, force-unlocks each run's lock by the ID that run reported, redeploys, and waits for readiness. Before 0.12, the same workflow ran every two days beside the server. That deployment's read latency is dominated by small-fragment counts, so it cannot simply skip compaction.
Directions
-
Run maintenance in the owning server (server: coordinated optimize and cleanup operations with resumable status #646). The server already admits writes, merges and schema apply under its own admission. Optimize and cleanup can be operations of the same process:
- authorized by a policy action;
- with an operation ID and status that survive a lost response, as served deployments do.
Optimize publishes once and fails when a write lands on a staged table, so a served run needs a bounded retry or a per-table batch, or a busy table starves it. Feature request: table scoping for
optimize(--table / --exclude-table) #432 asks for table selection. -
Release a direct command's lock once its I/O has settled. The server already does this on clean shutdown (lib.rs#L3050-L3075): it closes the admission's storage I/O scope, waits for it to go idle, refuses if any outcome is uncertain, then calls
release_after_settlement. A direct maintenance command could:- run its engine under the admission's I/O scope;
- apply the same proof when it finishes;
- release its own lock.
A command with an uncertain outcome keeps the lock and says so, as now.
-
Document the boundary until (1) lands. On a cluster-served graph, a direct maintenance command requires the serving owner to be stopped, and each run's lock must be released before the next.
Acceptance
- Optimizing every graph of a served cluster needs no server restart and no
force-unlock. - Until then, a direct maintenance command that completes with settled storage I/O leaves no lock behind, and one with an uncertain outcome keeps it.
- A CLI integration test runs two direct
optimizecommands in a row against a stopped cluster with no unlock between them.
Activity
ragnorc commentedon Oct 20, 2026
Summary
On a served cluster,
optimize,cleanup,repair, andrebuild-full-text-indexescannot start while the server is up. Each direct command takes the one cluster lock. A command that finishes keeps that lock. The next graph then stops until an operator runscluster force-unlockwith the exact lock id. The engine operation is safe to plan beside writers. The exclusion comes from admission.Status of this issue
The issue is still open. The two comments are the reporter's production measurements from 2026-10-18. No pull request implements a fix. This note is a reproduction on current
main, plus a design assessment.Reproduction
Tree:
b692610c4aa31066cb6a9fd34830f56977288943(2026-10-20). The report used0a62bb7.Binary:
omnigraph 0.14.0, internal schema 16, HTTP contract 0.13, Lance13.0.0.cargo build -p omnigraph-cli -p omnigraph-server --locked --offlinefinished with no rebuild, so the binary matches this tree.Host: Linux, local
file://storage, no cloud credentials. Two empty graphs,aandb, each with onePersonnode type. Root:/tmp/og1082/data.Claim 1 — reproduced
The four direct commands call
ensure_graph_with_bindingbeforeOmnigraph::open(maintenance/mod.rs, also L80, L96, L162). For a cluster graph that acquiresClusterAdmissionPurpose::GraphOperation(admission.rs). The server acquires the same__cluster/lock.jsonwith purposeServeand holds it for the process lifetime (lib.rs).Server command:
The lock file was
01M5DFJ1FT6YGE7R5KWWRR71FG,operation: serve. Each command below exited 1 withstate_lock_heldfor that id. The lock file did not change.Error text:
The graph path does not bypass the lock.
Claim 2 — reproduced
GET http://127.0.0.1:18082/openapi.jsonreturned 200 and 34 paths. No path contains optimize, cleanup, repair, or maintenance. These returned 404 with an empty body:PolicyActionhas no optimize or cleanup variant (Read,Export,Change,SchemaApply, branch and commit actions,Admin,GraphList,InvokeQuery,ConfigManage).exited 2:
The maintenance guide still says these commands do not run through the HTTP server (
maintenance.md). That page does not say that on a served cluster the command cannot start.Claim 3 — reproduced
cluster applyexited 0 and kept lock01M5DFHFRB5SQKNN56ZZMD1R43(operation: deployment) after the process exited.optimize --graph aexited 1 withstate_lock_heldfor that id.returned
{"unlocked": true, "lock_id": "01M5DFHFRB5SQKNN56ZZMD1R43"}and removedlock.json.force-unlockrequires an explicit--target. Thestate_lock_heldtext omits--target.optimize --graph athen exited 0. The graph had nothing to compact (committed: falseonnode:Personand__manifest). Stderr still said:cluster status --target /tmp/og1082/data --jsonreported that samelock_id.optimize,repair,cleanup --keep 1 --confirm --yes, andrebuild-full-text-indexesfor graphbeach exited 1 withstate_lock_heldfor01M5DFHQNZCDHT5EFF65BR690C.A CLI test on this tree fails the same way:
cli_cluster::second_graph_optimize_after_completed_optimize_needs_no_force_unlock_issue_1082. The fixture unlocks the apply lock, optimizesknowledge, then optimizesarchivewith noforce-unlock. The second command panics inoutput_successwithstate_lock_heldfor the first command's lock. The test is not in the tree. This assessment does not change the product.Claim 4 — reproduced at the admission boundary
The refusal is printed by admission, before the engine opens the graph. A standalone graph does not take a cluster lock:
That optimize exited 0. Stderr has only the target line. There is no
lock.json.The engine rule is unchanged. Optimize plans from the pinned table versions and publishes those pins in one manifest commit. A pin that another writer moved fails the run as a read-set conflict. The write-path guide states that (
writes.md).optimize_serializes_concurrent_delete_across_handlesincrates/omnigraph/tests/failpoints.rscovers two handles in one process: the second writer waits on the branch gate, then a strict delete prepared against the old head reports the typed read-set conflict. I did not remove the lock and run a second process beside optimize. Admission makes that start impossible.What differs from the report at
0a62bb713.0.0, not11.0.0(build(storage): upgrade lance to 13.0.0 #1072). The lock sequence is the same.--clusterwith--target.optimize --target http://…exits 2 withtarget_incompatible. The older sentence was that--serverdoes not apply.ensure_graphis nowensure_graph_with_binding. The retain sentence is the same.0a62bb7counted 33. The added surface is access, not maintenance.SIGTERMremovedlock.jsonwithin about 2 seconds.SIGKILLleft the serve lock in place (01M5DFMNMXFK7MPCDR3YETV767, dead pid). A direct command has no equivalent release.DropforClusterAdmissiondoes not callrelease_after_settlement. The CLI sources have no storage I/O scope.The six-graph S3 times in the comments were not measured again.
Root cause
__cluster/lock.jsonis the writer fence for the whole cluster root. The server takes it before it serves, and releases it only after a clean shutdown closes the storage I/O scope, waits until that scope is idle, refuses when an outcome is uncertain, and then callsrelease_after_settlement(lib.rs).A direct maintenance command takes the same object and leaves the guard to
Drop. The CLI states why: success of an arbitrary command is not a settlement proof (admission.rs). The control-plane guide says the same thing: a return from writable work, including success, retains the lock (control-plane.md).The in-process write queue is what serializes optimize with other writers in that process. A second process does not share it. The publication CAS is what rejects a stale pin. Cleanup is stricter: it deletes files, and the invariants keep it inside one writer process. #1094 records the read-side cost of that deletion order. A second process that only shares the cluster lock does not hold those gates.
Design
Run the four operations in the server process that already holds the lock, the write queue, and policy. That is #646. Keep one writer and one settlement proof.
What we give up is a second process that compacts while the server runs. That second process cannot share the write queue. The need in this issue is an available server, not a second writer. The long single-graph optimize (the
personalgraph, about 577 seconds with nothing to compact) remains a bound on that graph's writers. It stops being downtime for the other graphs.1. Maintenance in the owning server (#646)
This is the change that removes the outage.
optimize(--table / --exclude-table) #432 is table selection. A single call that holds the branch gate for many minutes stalls writers on that graph. A per-table batch can publish on its own and record which tables finished. Say that in the operation status. One publication per batch keeps the manifest rule. It is not one publication for a whole multi-hour pass unless the pass is one batch.2. Release a direct command after settlement
Do this as well, for the outage window that remains until (1) is in use. It does not let the command start beside a live server.
Copy the server shutdown sequence: run the engine under the admission's storage I/O scope, close the scope, wait until it is idle, keep the lock when any outcome is uncertain, then
release_after_settlement. An uncertain command says so and keeps the lock.The CLI does not enter that scope today. Deleting
lock.jsonon exit 0 is the wrong patch. A killed process, a lost acknowledgement, and a late PUT are why the retain rule exists.SIGKILLof the server left the lock. That is the sameDropbehavior.#1096, which implements part of the #1093 RFC, scopes an unknown storage outcome to its key. After that lands, the release proof uses that scope. On this
main, uncertainty is still process-wide.3. A per-graph lock is the wrong fix
The one root lock matches one server that owns the root. A per-graph lock would let graph
bstart while graphais locked, and it would let a second process write. #1017 (accepted 2026-10-20 as a document) does not lift the single-writer boundary. #1097 and #1093 are still changing this one lock into a lease. A second fence beside that work adds stuck records and a new takeover rule. It does not give the second process the write queue, and it does not make cleanup safe.Open work that touches the same lock
force-unlock. A CLI writer or a deployment still refuses a held lock at once. A dead server can be replaced. The next graph'soptimizestill cannot. This PR does not remove the maintenance outage.optimize(--table / --exclude-table) #432: choose tables, so one served run has a bound.Liability
Served maintenance adds one operation to an authority that already exists. The new obligations are the policy action, the operation record, and a bound on how long one graph's writers wait. Those are visible and testable.
Release-after-settlement removes the operator step that types an exact lock id between two successful graphs. The hazard is a release before a late write is durable. The server path already refuses that case. Use that path.
A per-graph lock adds a fence that the #1097 lease does not know. Two owners, two stuck records, and cleanup in a process that does not hold the writer's gates. That is more liability than the outage it removes.
force-unlockremains the operator tool for a lock whose owner is dead and whose I/O is not proved. It is not the steady step between two successful optimizes.Lance
Lance does not cause this, and a Lance release does not fix it.
The pin is unmodified Lance
13.0.0. At0a62bb7it was11.0.0. #1072 is the bump. No open pull request in this repository upgrades Lance. Onlancedb/lance, the latest stable release is v13.0.0 (2026-10-07). v14.0.0-beta.21 (2026-10-19) is a pre-release.The cluster lock is OmniGraph's
__cluster/lock.json. Lance has no equivalent object.Graph-table optimize writes detached commits. Those commits do not move HEAD and do not take Lance's conflict pass (
lance.md, Detached commits). The read-set conflict in the maintenance guide is the manifest pin CAS.Lance's transaction page describes optimistic concurrency for linear commits. A Rewrite, which includes compaction, conflicts with overlapping fragments and can rebase when the operations are compatible: Transactions. The read/write guide treats compaction as a file rewrite, not as a cluster lock: Read and write. Open Lance work on compact-versus-update and compact-versus-reindex rebase is lance#3775 and lance#5143. That work applies to Lance's linear conflict resolver. OmniGraph's detached optimize path does not use it. Shipping that resolver would not let a direct command start beside the server, and it would not release the lock after the command.
This credential cannot search
lancedb/lanceissues (the search API returned 422). The release list, the transaction page, and the public record of those two issues are the Lance evidence here.Test plan
crates/omnigraph-cli/tests/integration/cli_cluster.rs.applied_two_graph_clusteralready unlocks the apply lock. Runoptimizeonknowledge, thenoptimizeonarchive, with noforce-unlock. Both exit 0. Repeat forrepair,cleanup --keep 1 --confirm --yes, andrebuild-full-text-indexes. The test named above fails on thismainand is the regression for the second command.omnigraph-serveron that root. The four direct commands exit 1 withstate_lock_heldfor the serve lock.SIGTERMremoveslock.json, and the nextoptimizeexits 0.SIGKILLleaveslock.json.v2_root_admission_blocks_cli_write_and_native_control_doorsalready covers an in-processServeadmission. It does not cover process exit..gqtcase cannot see the lock. Keep the tests in the CLI and server owners.This issue stays open
#646 is the served-operation issue, and it is still open. Its acceptance does not include "a finished direct command releases a settled lock". #1095, #1021, and #1068 reference this issue. None of them has the same scope. This issue stays open.
Limitations
Local files only. The six-graph S3 times were not measured again. No second process wrote beside optimize with the lock removed. The failing CLI test was run on this checkout and then removed from the working tree.
ragnorc commentedon Oct 22, 2026
v0.14.0 turns this from a downtime cost into a hard gap for S3 clusters.
inline access supports local storage only; configure a server or managed target), anddocs/user/operations/maintenance.mdsays cloud-direct maintenance is not exposed. Sooptimizeandcleanupcan no longer address an S3 cluster root from the CLI, and there is still no served route.optimizeagainst the root, release that run's lock by ID, restart) is closed too. On S3 a v0.14.0 server's clean release rewrites__cluster/lock.jsonas a released version-3 record instead of deleting it, and older binaries refuse that record, so a pre-0.14 CLI can't take the lock once a v0.14.0 server has run there.A self-hosted S3 deployment that upgrades to v0.14.0 therefore has no supported way to compact or reclaim storage, and fragment counts and read latency grow until one exists. A served
optimize/cleanupadmitted under the serving owner's own lock (as schema apply is) would close it. A narrower interim would be to accept a cloud root as a direct target for the maintenance commands only.