Skip to content

fix(cluster): fence the storage scope at the lease deadline - #1221

Open
ragnorc wants to merge 3 commits into
ModernRelay:mainfrom
ragnorc:fix/cluster-lease-deadline-fence
Open

ragnorc wants to merge 3 commits into
ModernRelay:mainfrom
ragnorc:fix/cluster-lease-deadline-fence

Conversation

@ragnorc

@ragnorc ragnorc commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1217: review only the last commit. Until #1217 merges, this diff also shows its commits.

Closes the limit #1217 left open: after a pause of the whole process, a task other than the lease's could send a storage request past the owner's deadline.

What went wrong

The owner's fence ran in the lease's own task. LeaseKeeper fenced the storage scope when its deadline timer fired, so the deadline took effect only when that task ran. After a pause of the whole process (a stop signal, a stalled host, a starved runtime) any task may resume first. A writer that resumed before the lease task was admitted and sent its request past the deadline. A request already admitted could send a retry or a multipart part the same way. A successor may act five sixths of a lease after that deadline, so a long enough pause let the old owner write beside it.

The keeper had the same gap for its own beats. A beat it started after the deadline counted from that start, so with a store that answers at once, a resumed keeper could renew a lease that had lapsed during the pause.

The fix

  • The scope holds the deadline. StorageIoScope::fence_at(deadline) sets it, and the keeper moves it forward after each acknowledged beat, starting from the lock write's send. Admission, a multipart part, and every poll of an admitted request check it before sending. The first one to find it passed fences the scope: it refuses, cancels the admitted requests, and wakes them. Whichever task resumes first sends nothing past the deadline.
  • A fenced scope stays fenced. fence_at returns false once the scope is fenced, and the keeper then reports Lapsed, so the server exits instead of serving with a scope that refuses every write.
  • No beat starts at or after the deadline. A beat started then would count from a moment the lease no longer covered.
  • until_fenced now checks the fence before each poll of the request, not after. Before, a request woken at the same time as a fence was polled first and could send one more attempt.

Clean release is unaffected: it writes through a fresh scope that has no deadline, and its if-match decides the outcome at any time.

Docs: the scope paragraph (§4) and the owner and timing paragraphs (§7) of the storage-outcomes RFC, with a Decision-log entry naming the superseded sentence; the lease paragraph of docs/dev/control-plane.md; and docs/user/operations/storage-failures.md. New changelog note cluster-lease-deadline-fence.fixed.md.

Tests

  • lease::tests::a_paused_owner_sends_nothing_after_its_deadline_issue_1067 (new). It uses paused time and a holder adapter on the owner's scope. While the owner beats, the holder writes across three leases. Then the clock jumps a lease, and the next line runs before any other task. When the writer runs first, its write is refused before it is sent. When the lease task runs first, it starts no beat. Either way the owner fences Lapsed and the late file never exists. On the parent commit, both cases fail: the writer's write succeeds, and the keeper renews and never fences.
  • scoped::tests::a_scope_fences_itself_at_its_deadline_issue_1067 (new). A deadline moved forward before it passes keeps the scope open. A request admitted before the deadline, whose client would resend after it, sends one attempt and returns the ambiguous fenced error. Later parts, completion and new requests are refused as unsent. A fenced scope's deadline no longer moves.

omnigraph-storage gains tokio's test-util as a dev-dependency for paused time, as omnigraph-cluster already has.

Limit that remains

An owner whose monotonic clock stopped (a suspended VM) resumes with its deadline still ahead. Only the reads before its next beat stop it, up to a third of a lease later, as #1217 and the RFC state.

A successor ended its watch once a lease and a half had passed on its own
clock, so a listing whose answer stalled, or that it read late after a
pause, counted silence storage never showed, and it took the lock from an
owner that was still beating. The takeover then deleted the old lease
directory, claim included, and the owner, which read only the claim,
kept beating and serving beside the successor. A failed claim read also
counted as no claim.

Silence now counts only up to the send of a listing that found no newer
beat. Before each beat the owner reads the claim and lock.json, the record
whose if-match decides the owner and which never names a replaced lock id
again, and fences when either says the lock is no longer its own; a read
that gets no answer sends no beat and is retried until the deadline. A
takeover keeps the previous owner's lease directory, so an owner whose
clock stopped, or one that reads only the claim, stops at its next beat.
…ock bound

Review of the lease fix: `take_over` no longer deletes the superseded
owner's lease directory, so the mutable-key inventory's registration of
that `delete_prefix` described the defect and failed the count.

`state_lock::LOCK_RECORD_MAX_BYTES` is now the one bound on every read of
`lock.json`, so the lease's ownership check cannot read with a smaller
one and stop a live server at its deadline.

The control-plane and storage-failure pages say the owner stops on a
claim or a lost record at its next beat, at most a third of a lease
later; only the lapse is a timer of its own.
The owner's fence ran in the lease's own task, so the deadline took
effect only when that task ran. After a pause of the whole process any
task may resume first: a writer could be admitted and send past the
deadline, and an admitted request could send a retry or a part, before
the lease task fenced the scope. A beat started after the deadline also
counted from that start, so a resumed keeper could renew a lease that
lapsed during the pause.

`StorageIoScope::fence_at` gives the scope the deadline, which the keeper
moves forward with each acknowledged beat. Admission, a multipart part
and every poll of an admitted request check it before sending, and the
first to find it passed fences the scope. A fenced scope's deadline no
longer moves, and the keeper then reports the lapse. No beat starts at
or after the deadline. `until_fenced` checks the fence before polling
the request, so a request woken together with a fence sends nothing
more.

ragnorc commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: ready.

Why

The owner's right to write ends at one monotonic deadline. On a7533b6a that deadline lived only in the lease task. After a pause of the whole process, a writer, a retry, or a multipart part could send first. The lease task could also start a beat after the deadline and count that beat from the later time. A successor can take the lock while that late beat still looks fresh.

What

StorageIoScope holds the deadline. fence_at stores it. The keeper moves it forward after each acknowledged beat. The count still starts at the send of the lock write, then at the start of each acknowledged beat, before the pre-beat reads. Admission, a multipart part, and every poll of an admitted request read the deadline. The first task that finds it passed fences the scope, cancels admitted requests, and wakes them. Beat::once returns Lapsed and sends nothing when it is entered at or after the deadline.

How the invariant holds

Every scoped mutation checks the deadline and sends only while that deadline is still ahead, whichever task runs first. begin and begin_child refuse a new request before the client is called. until_fenced checks the deadline before it polls the request, so a later attempt does not start. Lance ObjectWriter (lance-io 13.0.0) submits part tasks through MultipartUpload::put_part. ScopedUpload::put_part checks the deadline before the inner part future exists. In object_store 0.14.2 the S3 part retry loop stays inside that future, and the default HTTP connector is ReqwestConnector. The next poll of until_fenced therefore sees the fence before another attempt. Dropping the future stops further attempts. Bytes the kernel has already accepted can still land.

Heartbeats and the claim use the lease client, outside the scoped store. The deadline still covers the calls that can extend the lease.

  • Heartbeats, the pre-beat reads, the watch, and the claim use lease_adapter. once refuses to start a beat at or after the deadline.
  • A beat that has already entered once can still be put after the deadline. It counts from the start taken before the deadline. If that start is s1 and the previous acknowledged start is s0, then s1 < s0 + 2/3 of a lease, so the new deadline is before s0 + 4/3 of a lease. A successor that still counts from s0 cannot take the lock before s0 + 3/2 of a lease. The gap stays above one sixth of a lease. A beat started after the deadline is the case that can pass the successor. That start is refused.
  • A clean release writes lock.json through a new scope with no deadline. That path is an orderly release. A lapsed lease exits without a release. The release and a successor still meet on one if-match.
  • Reads stay open. They do not renew the lease.
  • Azure locks are unleased, and this scope is not installed there.

A request already in the kernel or the SDK when the deadline passes can still land. The next poll sends nothing more. The RFC already treats that the same way as a crash: a fresh key, a conditional publication, or an audited mutable key.

The clock is tokio::time::Instant. It follows CLOCK_MONOTONIC. It advances through SIGSTOP, a cgroup freeze, and a runtime stall. It does not advance while a VM is suspended. The RFC already leaves that stopped clock to the next beat, the claim, and lock.json. This change does not tighten that case. a_superseded_owner_whose_clock_stopped_fences_at_its_next_beat_issue_1067 still passes.

The scope is the right owner of the check. It is already the mutation send point. The keeper remains the owner of the instant, from the two-thirds rule. One Option<Instant> sits beside the fence flag. fence_at stores one instant on the existing fence flag. The keeper is the only caller. Each of its calls stores a later instant, because acknowledged only increases. is_fenced fences on the first notice after the deadline. Its callers are this scope and the lease tests.

Impact

A paused server stops storage writes at its deadline. That includes a writer, a retry, and a multipart part. A resumed keeper leaves a lapsed lease unrestored when the beat would start after the deadline. The server still exits 2 without releasing. Successor silence is unchanged.

Each poll of an admitted mutation takes the scope mutex and reads the clock. Reads skip that check. There is no benchmark. The cost sits beside a storage call.

GQT cannot express task order or a clock jump. dst_successor_takes_over_a_dead_owners_lease_issue_1067 passes scope: None and models a dead owner. A live paused owner belongs to the lease test and the scope test. The read set and the write set of each operation stay the same, so cost_table.txt stays as it is.

Blocking

None.

Optional

  1. The pause test waits one lease for Lapsed. See the inline note on a_paused_owner_sends_nothing_after_its_deadline_issue_1067. The implementation already refuses the post-deadline beat. The stronger assertion is worth keeping so a later one-beat renewal cannot pass the wait.

Liability

The fence and the send point are now the same object. One check on the scope covers that pause for every scoped caller. A further deadline is another fence_at on the same flag. A second copy of the instant would be able to drift. Keep the single field.

The new surface is StorageIoScope::fence_at and the side effect on is_fenced. Both stay local to the scope. The two-thirds formula stays in the keeper.

Open work does not pull this apart. #1217 is the parent protocol. #1176 changes conditional control-object re-issue and does not move this deadline. A future Lance write path that bypasses ObjectStore would also bypass fence. That limit already belongs to the scope.

Validation

Head: 7d0577bb33c56d0aff5768c4dbe53221dd550954. Base for this commit: a7533b6a4881e7532b1553828d0a7a47a6a93594. At posting time the pull request was open at that head, with no review and no comment.

Local isolated worktrees. Temporary probes were removed. Nothing was pushed.

On a7533b6a, a temporary copy of the pause test failed for the predicted reason:

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-cluster --lib a_paused_owner_sends_nothing_after_its_deadline_issue_1067 -- --nocapture

Failed. writer-first write: Ok(()). For both task orders the fence wait returned Err(Elapsed), the scope was not fenced, and beats ...08 and ...09 were replaced by beats ...11 and ...12.

On 7d0577bb the committed tests passed, after a rebuild from that tree:

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-cluster --lib lease::tests -- --test-threads=8

12 passed, including a_paused_owner_sends_nothing_after_its_deadline_issue_1067.

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-cluster --lib a_superseded_owner_whose_clock_stopped_fences_at_its_next_beat_issue_1067

Passed.

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-storage --lib a_scope_fences_itself_at_its_deadline_issue_1067 -- --nocapture

Passed.

Before that restore, the same pause test with an added beat-listing equality also passed on 7d0577bb for both task orders. That edit is not in the commit.

CI on 7d0577bb (actions run 41263655584 and the sibling GQT and DST runs): Test Workspace, clippy, rustfmt, the DST pinned suite, GQT, S3, Azure, and the CI Gate passed. Devin Review skipped because the trial has no credits. That check supplies no review of this head.

Limits

No local measurement of the per-poll clock read. No SIGSTOP run against a live bucket. The pause in the proof is the paused Tokio clock. The full DST suite was not rerun locally. Bytes already in the kernel are outside what these tests observe.

Inline comment intended for crates/omnigraph-cluster/src/lease.rs line 986, side RIGHT, commit 7d0577bb33c56d0aff5768c4dbe53221dd550954:

The wait here is one full lease. A keeper that sent one beat after this jump, and counted that beat from the time after the jump, would set a new deadline two thirds of a lease ahead and then fence inside this wait. The test would still pass.

Please compare the beat names from before the jump with the names after the fence, for both orders. I ran that comparison on this commit. The names were the same. On a7533b6a the same pause sends the write (Ok(())), the wait times out, and beats 8 and 9 are replaced by beats 11 and 12.

This is optional. The entry check in Beat::once already refuses that start.

@ragnorc
ragnorc added this pull request to the merge queue Nov 1, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Nov 1, 2026
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