Skip to content

feat(cluster)!: take over a silent server's S3 lock through a lease - #1097

Merged
ragnorc merged 44 commits into
ModernRelay:mainfrom
ragnorc:feat/cluster-lock-lease
Oct 21, 2026
Merged

ragnorc merged 44 commits into
ModernRelay:mainfrom
ragnorc:feat/cluster-lock-lease

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1096. Review the commits after the merge of fix/per-operation-storage-outcomes. This implements §7 of the draft RFC #1093 and should merge only after that RFC is accepted.

What & why

On S3 roots, a server's cluster lock now carries a lease, so an orchestrator can restart a failed server without cluster force-unlock. This closes step 4 of #1067. On 2026-10-19 the contained process kept __cluster/lock.json with no expiry, Railway's restart policy was NEVER, and recovery took an operator and about 16 hours.

Only a server's S3 lock is leased.

  • A takeover must replace lock.json without racing a release the old owner already sent, and only a conditional update does that. Local roots have none, so a successor's overwrite could be undone by an exact-owner delete the old process had already sent. Local and Azure locks keep the exact-ID unlock, as today.

  • A CLI writer or deployment has nothing that stops its writes when a lease fences, so its lock stays unleased too.

  • Record. A held S3 lock is version 3 and records lease_seconds: 30 by default, or OMNIGRAPH_CLUSTER_LEASE_SECONDS within 2–600. A record outside those bounds is refused, so a corrupt lease cannot hold a successor off. Older binaries refuse version 3, so they neither take it over nor release it.

  • Where the lease's requests go. Heartbeats, the watch, the claim and the removal of a lease directory go through the root outside the owner's storage scope, on the backend's retrying client (ClusterStore::lease_adapter). A beat is a key only its owner creates, under a lock id no other process uses, so a copy that lands late only keeps a successor waiting. A failed beat leaves no key unsettled, and beats continue after the scope closes for a release.

  • Owner.

    • It creates __cluster/lease/<lock_id>/beat-<seq>.json put-if-absent every third of a lease, the first before it admits work. It checks for superseded.json before each beat.
    • A beat already present is the owner's own earlier request. It counts as acknowledged from the first send of that beat. A beat that got no answer is sent again, under the same key, after a quarter of a beat interval.
    • A timer of its own fires two thirds of a lease after the first send of its last acknowledged beat, so a hung beat cannot postpone the fence. A beat and the deadline ready together count the beat. A claim fences it at once.
    • The first beat races the same deadline, counted from the send of the request that wrote the lock record, because a successor may count silence from the moment the record is visible. If it lapses, or the claim read after beat 0 finds a claim, acquisition fails and the process does not serve.
    • A fence fences the owner's storage scope (StorageIoScope::fence, new here) before it is reported. Every later request is refused, including the parts, completion and abort of an admitted multipart upload. Every admitted request is cancelled: its future is dropped, so its client sends no further attempt or retry. A fenced owner therefore sends what a crashed owner sends, however long its shutdown grace lasts. The server then contains: admission closes, and it exits 2 without releasing.
  • Successor. Only serving admission takes over; a CLI writer or deployment still refuses a held lock at once.

    • It watches the beat listing on its own monotonic clock. A new beat refuses it with state_lock_held. An empty listing starts the silence, and the first beat that appears restarts it, because the owner writes its lock before beat 0 lands.
    • After a lease and a half of its own silence it makes sure the claim exists, then replaces lock.json by if-match on the version it first read. The claim elects nobody: it only fences an owner that is somehow still running, and a claim already there, from any successor, changes nothing.
    • That if-match alone decides the next owner: of two successors, or of a successor and the old owner's release (also if-match), exactly one applies. A successor that stops between its claim and its if-match leaves lock.json unchanged, so the next start takes it over the same way.
    • It removes the superseded owner's beats.
  • Clean release. It stops beating and deletes the lease directory, then releases as before. If that release fails, the lock is a silent leased lock, and the next server takes it over.

  • Timing. Every counted beat became visible no earlier than its first send, so a takeover happens at least 1.5 leases after it. The owner fenced by 2/3 of a lease after it. The gap of 5/6 of a lease covers clock-rate drift and a stalled runtime; no clock sync is needed.

Correctness does not rest on the lease. A request a dead owner sent before it fenced is one of these kinds, and none can corrupt state:

  • a fresh key, which stays unreachable;
  • a conditional request, which applies at most once against the version the successor also writes; the old owner's release is one;
  • an audited mutable key (fix(server): scope an unknown storage outcome to its key and branch #1096's guard). This PR registers its own: the owner's old beats, and the removal of a superseded or released lease directory;
  • a beat or lease-directory delete under the old lock id, which no successor watches.

Backing issue / RFC

Checklist

  • Change is focused (one logical change: the lock lease)
  • Tests:
    • storage scoped::tests::a_fence_stops_admitted_requests_and_later_parts_issue_1067:
      • at every mutation boundary (put, delete, copy, rename, multipart start, part, complete, abort), a request admitted before the fence ends with an ambiguous error once the fence lands;
      • an upload admitted before the fence sends no part, completion or abort after it;
      • reads continue.
    • cluster lease::tests under paused time:
      • refuses while beats continue;
      • claims after 1.5 leases, with one winner of two;
      • a lapse fences by 2/3 of a lease, and a claim fences;
      • one_failed_heartbeat_does_not_fence_a_live_owner_issue_1067: one failure, then three in a row, inside the deadline;
      • a_landed_beat_whose_ack_was_lost_refreshes_the_owner_issue_1067: six beats applied with lost answers do not fence the owner, and a successor still sees them;
      • a_hung_first_beat_fences_before_a_successor_can_claim_issue_1067: start returns the fence and no keeper by 2/3 of a lease after the lock write, the successor claims only after 1.5 leases, and the holder's scope refuses writes;
      • a_claim_during_the_first_beat_keeps_the_holder_from_serving_issue_1067;
      • a_claim_left_by_a_stopped_successor_does_not_block_takeover_issue_1067: the stopped successor and a different one both find the owner silent after their own 1.5 leases (the review's wedge observed Alive after 0 ns for both);
      • a_successor_finds_a_silent_owner_after_a_lease_and_a_half_issue_1067: two successors both find the owner silent, and the claim is in place.
      • These first three failed on cec09eaa, per the review.
    • cluster state_lock::tests, on an in-memory store with S3's conditional semantics:
      • a_restarted_server_takes_over_a_silent_owners_lock_issue_1067;
      • a_release_and_a_takeover_never_both_apply_issue_1067.
    • only_a_servers_s3_lock_is_leased_issue_1067.
    • a_takeover_survives_a_successor_that_stopped_after_its_claim_issue_1067: with a stopped successor's claim in place, two racing starts each wait out their silence, and the if-match picks exactly one.
    • a_local_lock_is_never_taken_over_issue_1067: a local lock, even one whose record carries a lease, refuses a server at once and stays unchanged.
    • On a local root, state_lock_is_exclusive_and_releases_on_drop: no lease, and a serving acquisition refuses a held lock at once.
    • The S3 cluster suite is back to its pre-lease expectation: a finished deployment's unleased lock refuses a server at once.
    • The server boot_settings local journeys are back to their pre-lease expectations: a retained lock refuses another server.
    • server operations::tests: a fenced lease closes admission and reaches the fail-stop path.
    • cluster state_lock wire test: version 3 needs a bounded lease, a release drops it, and version 4 is a future version.
    • The bootstrap fault fixture still forbids every delete except under the owner's own lease directory.
    • DST dst_successor_takes_over_a_dead_owners_lease_issue_1067: 16 seeds, with the dead owner's in-flight beat landing before, during or after the watch.
  • Public docs:
    • a "Restart after a failure" section on operations/storage-failures.md: restart policy, lease, and exact-ID unlock for local, Azure and older locks;
    • one pointer in deployment.md;
    • control-plane.md and troubleshooting;
    • changelog.d/cluster-lock-lease.changed.md.
  • Reviewed against invariants. The lease is a liveness protocol, documented as one and not as fencing (deny-list: process-local locks presented as distributed fencing). It uses create-if-absent, list, read, delete of lease keys, and the if-match replacement of lock.json.

Local verification

On 8acad43a (#1096 and main at d111c403 merged), with RUST_MIN_STACK=16777216, on macOS arm64:

  • cargo fmt --all --check: clean.
  • Six-package suite (engine, server, cluster, core, storage, catalog, with both failpoint features): 2770 passed, 0 failed.
  • DST, from crates/omnigraph-dst: 7 passed:
    • the lease takeover and dst_crash_state_enumeration_sampled;
    • the publication, ack-loss, fault-storm and end-to-end scenarios;
    • the cost golden.
  • Both workspace clippy graphs: clean.
  • check-docs.py, check-agents-md.sh, and typos on the changed paths: clean (run on f5b939d3; later commits touched one docs paragraph, which check-docs.py passed).
  • Not run: live S3 or Azure, or an injected HTTP 503 against a real store.

Notes for reviewers

  • Restart time. After a crash on S3 the restart waits about 45 seconds with the default lease. A clean SIGTERM still releases at once.
  • Mixed versions. Older binaries refuse the version-3 record; run one writer version per cluster, as today.

A served write whose publisher failed before its conditional manifest put
was reported in doubt, and the server contained itself. On 2026-10-19 a
throttled `__manifest/_transactions/*.txn` PUT did this: Lance writes the
transaction file before the manifest and returns its error with a bare
`?`, so no manifest request was sent, but the publisher's read-back rule
called absence definite only for a failure before any store request.

Every OmniGraph dataset commits through one configured commit handler.
It now re-issues an ambiguous manifest put-if-absent unchanged, up to four
times, until the store answers it; a held version goes back to Lance's
transaction read-back, which tells this commit from another. A per-commit
probe records whether a request was sent and how it ended.

The catalog publisher derives each failure's completion evidence from that
record and the attempt's phase: no request sent, or one the store refused,
is `Unpublished`; a request that may have applied is read back, and only an
outcome the read-back cannot settle stays in doubt. Every other publisher
failure, candidate writes included, is `Unpublished`; a candidate tag
whose create may still land is in doubt.

The server's `ApiError` carries the engine failure's redacted class, and a
contained owned write logs it before admission closes.
The handler result frame is sized by ApiError, which a guard keeps at four
words. The redacted cause is logged where the engine error is translated,
and a contained owned write logs its status once.
…uests

A slot rule in the Lance-realm store fails, throttles, loses the
acknowledgement of, or lands late the graph's publication requests, which
the seeded weather cannot target. The scenario requires each to end
committed once, unpublished, or in doubt with nothing visible, and the
next write on the handle to publish.
The re-issue logs named the dataset's base path and the store's error text,
which can carry object URIs. They now record the request's object-store
error class, as every other server storage log does.
The deployment page is at its length cap. Storage outcomes get their own
operations page, which the deployment page's shutdown section points to.
A storage request with no definite answer stopped the whole process: the
storage scope kept one process-wide uncertain flag, and an in-doubt
publication or an opaque storage error contained the server until an
operator unlocked it.

- The storage scope tracks unsettled mutable keys instead of one flag. A
  key whose request has an unknown outcome refuses later requests to that
  key, including a recursive delete over it, and nothing else. The set is
  bounded; past the bound every mutation is refused, and a clean release
  still requires it empty.
- Requests route by class. Reads and fresh immutable writes go through a
  retrying client within Lance's defaults; conditional requests and mutable
  keys keep one attempt per request, and their owners resolve ambiguity.
- A `__manifest` commit whose manifest request no re-issue settles is
  pending on its branch. The process keeps the base and the exact Lance
  transaction; the branch's next publication re-issues it once and refuses
  with the typed `PublicationPending` while storage stays silent. The server
  re-issues pending commits on a backoff and once more at clean shutdown,
  which retains admission if any stay pending.
- The server contains only uncertainty the engine does not scope: a panic,
  a lost owner, `RecoveryRequired`, an opaque execution failure and an
  uncertain activation. A pending publication answers 503.
- A guard registers every unconditional request to a reused key across the
  crates that write storage, with why its late landing is harmless.
A contained or crashed server kept `__cluster/lock.json` with no expiry, so
a restart needed an operator's exact-ID unlock.

On local and S3 roots a held lock is now version 3 and records
`lease_seconds` (30 by default, `OMNIGRAPH_CLUSTER_LEASE_SECONDS`, 2 to 600).
Its owner creates `__cluster/lease/<lock_id>/beat-<seq>.json` put-if-absent
every third of a lease and checks for a claim before each beat. A timer of
its own fences it two thirds of a lease after the send time of its last
acknowledged beat, or at once on a claim; the server then contains and exits
2 without releasing.

A serving acquisition that finds a leased lock watches the beat listing on
its own monotonic clock. A new beat refuses it; a lease and a half of
silence lets it create the claim once and replace the lock, by if-match on
S3 and by overwrite on a local root, where the claim has serialized
successors. Every other acquisition still refuses a held lock at once. A
clean release stops beating and removes the lease directory first.

Azure roots keep the unleased version-1 record. Older binaries refuse the
version-3 record.
…nce 13

Lance 13's WrappingObjectStore also wraps paginated listings; the fault
aims at puts only.
…t may land

After the last re-issue the handler returned the store's error and kept
reporting a missing version as definitive, so Lance read the version back
as absent and deleted the attempt's transaction file. A manifest that lands
later then names a file that is gone. Only the server's storage scope held
that delete back; embedded and CLI opens did not.

The handler now records the task whose request no re-issue settled, the task
Lance reads the version back on, and reports a missing version as not
definitive there. Lance returns CommitStatusUnknown and keeps the file. A
refused request and a commit that sent none keep the inner answer.
…tion-storage-outcomes

# Conflicts:
#	crates/omnigraph-catalog/src/tests.rs
#	docs/dev/writes.md
Lance deletes an attempt's transaction file when the handler calls a
missing version definitive, and reads that answer with no reference to
the attempt. The per-task marks that stood in for one relied on Lance
reading the version back on the task that sent the request.

The catalog's per-commit handler now answers from its probe, keeping the
inner answer unless the request may still land. A dataset's shared handler
holds no record of the attempt Lance reads back, so it never calls a
missing version definitive; Lance reports the status unknown and the
collector's orphan sweep reclaims a table's leftover transaction file.
A publication whose manifest request no re-issue settles was kept in a
process registry, refused its branch with a 503 and was re-issued by a
server resolver until storage answered. That registry repeated a guarantee
the version key already gives: the manifest version is created only if
absent, so the request lands before the branch's next publication or
never. The registry also landed a crashed operation's commit under a
rerun that had already captured its authority, so the rerun failed instead
of converging.

The registry, its error, the resolver and the shutdown hold are gone. An
unknown publication answers in doubt and the branch publishes on: the
next publication finds the late commit or takes its version first. A
regression test covers both orders.
…tion-storage-outcomes

# Conflicts:
#	docs/dev/writes.md
…rage-outcomes

# Conflicts:
#	crates/omnigraph-catalog/src/tests.rs
#	crates/omnigraph-server/src/lib.rs
#	crates/omnigraph-storage/src/scoped.rs
#	docs/dev/writes.md
#	docs/user/deployment.md
#	docs/user/operations/storage-failures.md

ragnorc commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation

Do not merge cec09eaa.

A lease is the right tool when the owner is dead. This head treats one retryable storage error as death, and it can leave two processes writing the same cluster. Fix the heartbeat, the first-beat deadline, the local lock update, and the shutdown bound together. Keep one conditional update of lock.json. Do not add a second lock.

RFC 1093 section 7 states two of these rules. Update that RFC in the same change. A beat that already exists must refresh the owner. The operation list must include the S3 if-match replace this code already performs.

Blocking

  1. One failed heartbeat fences a live owner.

Heartbeats are put-if-absent. ScopedObjectStore::client sends every conditional request, and every mutable key, on the single-attempt client (max_retries: 0). The scope classes __cluster/lease/.../beat-*.json as mutable. A failed attempt leaves that key unsettled, and the next put of the same name is refused before it is sent. Beat::once leaves seq unchanged on Err, so the retry uses the poisoned name.

The keeper then fences without a second attempt. After one acknowledged beat, the next beat waits one third of a lease. A failure leaves acknowledged unchanged. The following beat is scheduled at the same instant as the deadline (acknowledged + 2/3 of the lease). The biased select takes the deadline first and drops that beat.

docs/user/operations/storage-failures.md says two unanswered heartbeats exit 2, and the same page says one failed request never stops the server. This path exits after one error.

A temporary test on this commit, one_failed_heartbeat_does_not_fence_a_live_owner_issue_1067, observed Some(Lapsed) at 20 seconds for a 30 second lease.

Send fresh put-if-absent heartbeats on the retrying client. A failed create of a fresh beat name must stay retryable. Keep sending until the deadline. When a beat and the deadline are both ready, send the beat.

  1. A landed beat whose acknowledgement was lost fences the owner.

Ok(false) means the create is already present. That beat is visible now. The code advances seq and leaves acknowledged unchanged. The same biased select then fences at the last success plus two thirds of a lease.

a_landed_beat_whose_ack_was_lost_refreshes_the_owner_issue_1067 observed Some(Lapsed) at 20 seconds. Set acknowledged to the observation time. RFC 1093 section 7 says an existing beat does not refresh the owner. Change that sentence with this code.

  1. The first beat sits outside the fence timer.

LeaseKeeper::start awaits beat.once() before it arms the deadline. The lock file is already public. watch_and_claim starts its silence clock when it sees no beat, and it claims after a lease and a half. The comment on start says a successor never observes a held version-3 lock with no beat for longer than the owner deadline. A hung first request breaks that sentence.

a_hung_first_beat_fences_before_a_successor_can_claim_issue_1067 finished with Ok(Claimed) while start was still waiting. When the request later returns, start returns a guard the server will serve with, for a lock the successor already claimed. Race the first beat against owner_deadline. After that deadline, return no guard the server can serve with.

  1. Local takeover overwrites lock.json, and a late local release deletes the successor.

take_over calls write_text on a local root and reports success. release_settled_lock reads the lock, checks lock_id, then deletes the key for local and Azure. The read and the delete are separate requests. A successor that replaces the key in that window loses the lock to the old delete.

A temporary test paused the ownership read, wrote a successor record, then let the release continue. The release returned Ok. The stored key was None. The successor id 01SUCCESSORLOCKID000000000000 was gone. write_text_if_match on the local adapter is still read, compare, and put. A shared directory has the same hole.

Refuse local takeover until the replacement and the release are one conditional update. The S3 if-match on the version the successor first read is the right shape. Add that operation to the list in the lease module docs and in RFC 1093 section 7. That list currently names create-if-absent, list, read, and delete.

  1. Shutdown grace can outlast the gap between fence and takeover.

The owner fences two thirds of a lease after its last acknowledged send. The successor may claim a lease and a half after the newest beat it saw. The gap is five sixths of a lease. That is 25 seconds at the default 30 second lease, and about 1.67 seconds at the minimum lease of 2 seconds.

On the fence path the server closes HTTP admission and arms the shutdown watchdog. The default grace is 25 seconds (DEFAULT_SHUTDOWN_GRACE). In-flight handlers still have an open storage scope. After HTTP shutdown, wait_logical_owners is false because containment set startup_uncertain, and the process calls process::exit(2) before scope.close(). The timing test in lease.rs compares only the two lease timers.

At the default lease the grace equals the gap. At a 2 second lease the fenced process can still mutate for about 23 seconds after a successor is allowed to claim. The takeover wait must cover the grace this server uses, or the fenced process must stop storage mutations before a successor may claim.

Optional

  • After a beat has been seen, an empty listing returns Alive and this attempt stops. That direction is safe. Say so in RFC 1093 section 7. An empty listing before any beat is finding 3.
  • When the S3 if-match loses because the owner released the lock during the watch, take_over returns false and this attempt reports the lock held. The next start can take a released marker.
  • dst_successor_takes_over_a_dead_owners_lease_issue_1067 writes the late beat under the dead owner's directory, then starts the keeper on a different lock id. It leaves the late beat on the taken lock untested.

Tests to add

A .gqt case cannot express cluster-lock ownership under delay. Please add these under the existing Rust owners:

  • one_failed_heartbeat_does_not_fence_a_live_owner_issue_1067 in crates/omnigraph-cluster/src/lease.rs
  • a_landed_beat_whose_ack_was_lost_refreshes_the_owner_issue_1067 in lease.rs
  • a_hung_first_beat_fences_before_a_successor_can_claim_issue_1067 in lease.rs
  • a_local_release_delete_removes_the_successor_lock_issue_1067 in crates/omnigraph-cluster/src/state_lock.rs

On cec09eaa the three lease tests failed as cited above. The local test, with key cluster/__cluster/lock.json, deleted the successor record. The checkout was restored after those runs. A local sketch of the lease fixes made the three lease tests pass, together with the existing silent-owner, claim, refuse, and lapse tests. That sketch was removed. The local delete was left red on purpose: a second read before the delete is still a check-then-act, and that sketch deadlocked the test on the second read.

Validation

Commit cec09eaa3cb9011290afd207944735433d768681.

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-cluster -p omnigraph-dst --lib -- \
  a_landed_beat_whose_ack_was_lost_refreshes_the_owner_issue_1067 \
  one_failed_heartbeat_does_not_fence_a_live_owner_issue_1067 \
  a_hung_first_beat_fences_before_a_successor_can_claim_issue_1067 \
  an_owner_fences_itself_when_its_beats_lapse_issue_1067 \
  -- --test-threads=8

Result on this commit, with only the new tests added: 1 passed (an_owner_fences_itself_when_its_beats_lapse_issue_1067), 3 failed. Some(Lapsed) at 20 seconds for the lost-ack and one-error cases. Ok(Claimed) while the first beat was still hung. The existing lapse test stays green because it refuses every later heartbeat, so it accepts a fence after one miss.

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-cluster -p omnigraph-dst --lib -- \
  a_local_release_delete_removes_the_successor_lock_issue_1067 -- --test-threads=1

Result: failed in 0.00 seconds. left: None, right: Some("01SUCCESSORLOCKID000000000000"). The release had already returned Ok.

omnigraph-dst is on those commands so Tokio test-util is enabled. start_paused does not compile in a cluster-only test build.

CI on this commit is green. Those jobs do not run these four cases. I did not run a live server, S3, Azure, or an injected HTTP 503. The grace overlap is from the constants owner_deadline, successor_silence, and DEFAULT_SHUTDOWN_GRACE.

Tradeoff

Correctness comes before a shorter lease or a single storage attempt. One failed heartbeat must leave the owner in place. The cost is more heartbeat retries, and a takeover that waits out real shutdown. Five more special cases around this lock would leave two clocks, a grace period, and a local overwrite as separate sources of truth. One conditional lock.json update, plus fresh heartbeat creates, should remain the only fence.

Inline comments

crates/omnigraph-cluster/src/lease.rs:175 — This first beat.once() is outside the deadline. The lock is already visible. A hung request leaves start waiting, and watch_and_claim claims after a lease and a half of missing beats. A test on this commit finished with Ok(Claimed) while this call was still hung. When the request later returns, this function hands back a guard the server will serve with. Race this call against owner_deadline, and return no servable guard after that deadline.

crates/omnigraph-cluster/src/lease.rs:187 — After one acknowledged beat, one Err leaves acknowledged in place. The next beat is then due at the same instant as this deadline. biased takes the deadline and drops the beat, so one transport error fences a live owner. A test on this commit observed Some(Lapsed) at 20 seconds for a 30 second lease. Send the ready beat. Heartbeats are fresh put-if-absent creates: send them on the retrying client, and do not leave the failed name unsettled.

crates/omnigraph-cluster/src/lease.rs:273 — Ok(false) means this owner's create is already stored and visible. Set acknowledged to the observation time before advancing seq. Leaving it unchanged fences the owner at the last success plus two thirds of a lease. A test on this commit observed Some(Lapsed) at 20 seconds. RFC 1093 section 7 states the same rule and needs the same change.

crates/omnigraph-cluster/src/state_lock.rs:378 — This overwrite is unconditional. release_settled_lock then reads lock_id and, for a local root, deletes the key as a later request. A test paused that read, wrote a successor record, and let the release continue. The release returned Ok and the key was gone (stored None, successor 01SUCCESSORLOCKID000000000000). Refuse local takeover until the replacement and the release are one conditional update. The S3 if-match above is the right shape, and it is missing from the operation list in lease.rs and in RFC 1093 section 7. write_text_if_match on local is still read, compare, and put.

crates/omnigraph-storage/src/scoped.rs:515 — A heartbeat is a conditional create, and this path also classes beat-*.json as mutable, so both reasons send it on the single-attempt client. One HTTP 503 leaves the beat name unsettled. The lease retries that same name, the scope refuses it before send, and the owner fences. Fresh put-if-absent heartbeats belong on the retrying client. A failed attempt must leave the name usable.

crates/omnigraph-server/src/lib.rs:3234 — contain closes HTTP admission. The storage scope stays open through graceful shutdown, and process::exit(2) runs before scope.close(). The default grace is 25 seconds. The fence-to-claim gap is five sixths of the lease: 25 seconds at the default 30 second lease, and about 1.67 seconds at the minimum 2 second lease. In-flight mutations can still land after a successor is allowed to claim. Stop storage mutations before that claim, or make the takeover wait cover this grace.

…rage scope

A heartbeat went through the serving scope's single-attempt client. One
unanswered beat left the owner one more chance before its deadline, and it
left the beat key unsettled, so the scope refused the owner's re-issue and
later its clean release. The lease now writes through the root outside the
scope, on the backend's retrying client. A beat already present is this
owner's own earlier request and counts from the first send of that beat; an
unanswered beat is sent again after a quarter of a beat interval. A
successor that finds a claim naming its own lock id holds it.

A local takeover overwrote lock.json, and an exact-owner delete the old
process had already sent could remove the successor's record. Local roots
have no conditional update, so their locks carry no lease and keep the
exact-ID unlock; only S3 takes a silent owner over, by if-match.
@ragnorc ragnorc changed the title feat(cluster)!: take over a silent owner's lock through a lease feat(cluster)!: take over a silent owner's S3 lock through a lease Oct 20, 2026
The first beat ran before the fence timer was armed. A hung first request
let a successor claim the lock, after which acquisition still returned a
guard the server would serve with. It now races the owner's deadline,
counted from the send of the request that wrote the lock record; when it
lapses, acquisition fails and the process does not serve.

A fenced server kept its storage scope open through its shutdown grace,
which can outlast the gap between the fence and a successor's claim. The
lease now closes the holder's scope before it reports the fence, so no
mutation is sent after a successor may act.

Only a server's S3 lock is leased: a CLI writer or deployment has nothing
that stops its writes when a lease fences, so its lock stays unleased with
the exact-ID unlock. A beat and the deadline ready together count the beat.
The crate's own test build gets Tokio's paused time.
@ragnorc ragnorc changed the title feat(cluster)!: take over a silent owner's S3 lock through a lease feat(cluster)!: take over a silent server's S3 lock through a lease Oct 20, 2026
A claim that appears while beat 0 is in flight now keeps the holder from
admitting work: the owner reads the claim once more after beat 0 is
acknowledged, and acquisition fails if one names its lock.
@ragnorc

ragnorc commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Every blocking finding of the review of cec09eaa is addressed in d1b546bf, da7f9d0e and f5b939d3. The RFC side is in #1093 at c8ba085b.

1. One failed heartbeat fenced a live owner. Heartbeats, the watch, the claim and the lease-directory removal now go through the root outside the owner's storage scope, on the backend's retrying client (ClusterStore::lease_adapter). A failed beat leaves no key unsettled. An unanswered beat is sent again under the same key after a quarter of a beat interval, until the deadline. In the keeper's select, a beat and the deadline ready together count the beat.

2. A landed beat whose acknowledgement was lost. Ok(false) now refreshes the owner. It counts from the first send of that beat rather than from the observation. The first send is the earlier bound, so the timing proof needs no assumption about how long the re-issues took. The RFC sentence changed with it.

3. The first beat sat outside the fence. LeaseKeeper::start now takes the send time of the request that wrote the lock record. The first beat races the owner's deadline counted from there. If it lapses, start returns the fence and no keeper, acquisition fails with StateLockError::LeaseFenced, and the process does not serve. The lock stays for a successor. The takeover and bootstrap-claim paths pass the send time of their if-match. After beat 0 is acknowledged, the owner reads the claim once more, and a claim fails acquisition the same way (f5b939d3).

4. Local takeover and the local release delete. Refused. Only a server's lock on S3 is leased, and takeover is the if-match on the version the successor first read. A local lock, even one whose record carries a lease, refuses a server at once. The if-match is in the operation lists of the lease module and of RFC §7.

  • I also made CLI writers' and deployments' S3 locks unleased. Nothing in those processes stopped their writes when a lease fenced, so a successor could have acted beside a live CLI writer.
  • The S3 cluster suite is back to expecting a finished deployment's lock to refuse a server at once.

5. Shutdown grace against the fence-to-claim gap. A fence now closes the holder's storage scope before it is reported (fence_holder), so no storage mutation is sent after a successor may claim, whatever the grace. Requests already in flight were sent before the fence, inside the lease. The server's containment then runs as before.

Optional.

  • An empty listing after a seen beat is now stated in §7.
  • After an if-match lost to a clean release, the attempt refuses and the next start takes the released marker. §7 says so.
  • The DST scenario's late beat lands under the dead owner's lock id at a seeded time, which can fall after the claim. It then checks that the successor's keeper is never fenced and that a third process sees the successor alive. A dead owner can only write under its own lock id, so that is the late beat a taken lock can receive.

Tests. The names you asked for, under the existing owners:

  • lease.rs:
    • one_failed_heartbeat_does_not_fence_a_live_owner_issue_1067: one failure, then three in a row;
    • a_landed_beat_whose_ack_was_lost_refreshes_the_owner_issue_1067;
    • a_hung_first_beat_fences_before_a_successor_can_claim_issue_1067: start returns Err(Lapsed) by 2/3 of a lease after the lock write, the successor claims only after 1.5 leases, and the holder's scope then refuses a write;
    • a_claim_during_the_first_beat_keeps_the_holder_from_serving_issue_1067;
    • a_successor_recognizes_its_own_claim_issue_1067.
  • state_lock.rs:
    • a_local_lock_is_never_taken_over_issue_1067, in place of a_local_release_delete_removes_the_successor_lock_issue_1067. With no local takeover, no successor writes a local record for a late delete to remove. The test pins that a local lock is never taken over;
    • only_a_servers_s3_lock_is_leased_issue_1067;
    • a_release_and_a_takeover_never_both_apply_issue_1067;
    • a_restarted_server_takes_over_a_silent_owners_lock_issue_1067, now on an in-memory store with S3's conditional semantics.
  • omnigraph-cluster now has Tokio's test-util as a dev-dependency, so start_paused compiles in the crate's own test build.

Verification on f5b939d3, with RUST_MIN_STACK=16777216, macOS arm64:

  • cargo fmt --all --check: clean.
  • Six-package suite (-p omnigraph-engine -p omnigraph-server -p omnigraph-cluster -p omnigraph-core -p omnigraph-storage -p omnigraph-catalog --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints --locked): 2755 passed, 0 failed. That includes all ten lease::tests, the six state_lock::tests and the boot_settings journeys.
  • DST, from crates/omnigraph-dst: 7 passed, 0 failed:
    • dst_successor_takes_over_a_dead_owners_lease_issue_1067;
    • dst_crash_state_enumeration_sampled;
    • dst_publication_requests_get_a_definite_outcome_issue_1067;
    • the ack-loss, fault-storm and end-to-end scenarios;
    • the cost golden.
  • Both workspace clippy graphs with -D warnings: clean.
  • check-docs.py, check-agents-md.sh and typos on the changed paths: clean.
  • Not run: live S3 or Azure, an injected HTTP 503 against a real store, and the canonical workspace test beyond these six packages.

… more

A lease fence closed the holder's storage scope. Closing refuses new
requests, but an admitted request kept retrying inside its client and an
admitted multipart upload kept sending parts until the process exited,
and the default 25 s shutdown grace outlasts the fence-to-claim gap of any
lease under 30 s.

`StorageIoScope::fence` refuses every later request, including the parts,
completion and abort of an upload admitted before it, and cancels every
admitted request still running: its future is dropped, so its client sends
no further attempt. The cancelled request's caller gets an ambiguous error,
since its last attempt may be on the wire. The lease fences the scope
instead of closing it, so a fenced owner sends what a crashed owner sends.
Main made ApiError's status a method; the owned-write log line read it as
a field.

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation

Do not merge e7dfd1ca.

A lease is the right tool when an S3 server is dead. A new server can then take the one cluster lock. The five blocking findings on cec09eaa are fixed on this head. One recovery hole remains. The successor writes superseded.json before it replaces lock.json. If that replacement does not finish, every later start sees the claim and stops at once. The dead owner's lock stays. An operator must unlock it by id. The restart promise needs a later server to finish that replacement.

What this change does

A server's S3 lock is version 3 and carries lease_seconds. The owner creates __cluster/lease/<lock_id>/beat-<seq>.json with put-if-absent, every third of a lease. A restarted server watches that listing on its own monotonic clock. After a lease and a half with no new beat, it claims the lock and replaces lock.json by if-match on the version it first read. The owner fences itself at two thirds of a lease after the first send of its last acknowledged beat. The fence stops further storage sends, then the process exits 2 and does not release the lock.

Local roots, Azure, CLI writers, and deployments stay unleased. They have no conditional replace, or nothing in the process stops their writes when a lease fences.

Earlier findings on cec09eaa

  1. One failed heartbeat fences a live owner. Resolved. Heartbeats, the watch, and the claim use ClusterStore::lease_adapter, outside the owner's storage scope, on the backend's retrying client. A failed beat leaves no key unsettled. The keeper sends it again under the same key after a quarter of a beat interval. In the keeper select, a beat that is ready is taken before the deadline.

  2. A landed beat whose acknowledgement was lost. Resolved. Any Ok from the create, including Ok(false), sets acknowledged to the first send of that beat. That instant is earlier than the observation, so the owner fences sooner. A successor that can see the beat waits longer. The split is the safe direction.

  3. The first beat sits outside the fence timer. Resolved. LeaseKeeper::start races beat 0 against the owner's deadline, counted from the send of the lock write. A lapse or a claim found after beat 0 returns the fence and no keeper. Acquisition fails with StateLockError::LeaseFenced. The server does not serve. The lock stays for a successor.

  4. Local takeover overwrites lock.json, and a late local release deletes the successor. Resolved. Only a server lock on S3 is leased. Takeover is the if-match on the version the successor first read. A local lock is refused at once, including a record that carries lease_seconds. S3 release is if-match on the version the owner read, so a release and a takeover cannot both apply.

  5. Shutdown grace can outlast the gap between fence and claim. Resolved on 88abc34, and scoped.rs is unchanged at this head. fence_holder calls StorageIoScope::fence before it reports the fence. The fence refuses every later request, cancels every admitted request still running, and refuses later parts, completion, and abort of an admitted multipart upload. f5b939d3 only closed new admission, so an admitted request could keep sending. A request the client has already sent can still land. That is the same case as a crash. Lance 13 creates a numbered manifest with put-if-absent, so one version has one winner. On stores whose listing is ordered, including S3, Lance 13 does not write latest_version_hint.json.

The three optional notes from that review are resolved. An empty listing after a seen beat is stated, and it refuses. A lost if-match against a clean release leaves the next start to take the released marker. The DST late beat lands under the dead owner's lock id. The successor renews a new lock id, so that beat cannot fence it.

Blocking

  1. A claim that does not finish the lock replace wedges every later restart.

watch_and_claim returns Alive as soon as superseded.json exists. It does not read the body. It does not wait out the silence. The check that recognizes this successor's own claim runs only when write_text_if_absent returns Ok(false) in that same call.

take_over creates the claim inside watch_and_claim, then replaces lock.json by if-match. If the process stops between those steps, or the if-match returns an error and does not apply, the claim remains and the lock record does not change. The owner sees the claim at its next beat, fences, and exits without a release. The next server has a new lock id. It gets Alive at once and reports state_lock_held. Silence never starts. The orchestrator restarts forever.

A temporary test on 88abc34, a_landed_claim_whose_replace_did_not_finish_wedges_takeover_issue_1067, observed same=Alive after 0ns, later=Alive after 0ns. The crashed successor's own id and a different successor id both stop immediately. lease.rs and state_lock.rs are identical at e7dfd1ca. The checkout was restored after the run. Please add that test under lease.rs. A .gqt case cannot express cluster-lock ownership under delay. The existing owner is omnigraph-cluster lease.rs.

The if-match on the version first read is already the single winner. A later successor can use it while lock.json is still that version. A claim left by a successor that never became the lock owner must not block that if-match. Keep one conditional update of lock.json. Do not add a second permanent election.

Tradeoff

Correctness of restart comes before a shorter claim step. The heartbeat path and StorageIoScope::fence remove the split-brain cases from cec09eaa. The cost is more heartbeat retries, and a takeover that waits a lease and a half. The remaining hole is liveness. One unfinished claim becomes a lock that no server can take. Five more objects beside lock.json would add five more places a crash can stick. One conditional update of lock.json, plus fresh heartbeat creates, should stay the only records that decide the owner.

Validation

Reviewed head: e7dfd1cae0cd4e04f7d7435483df727367233153. During this review the head moved from f5b939d3 to 88abc34 (fix(cluster): fence the storage scope so a lapsed owner sends nothing more), then to e7dfd1ca (a merge). lease.rs, state_lock.rs, and scoped.rs do not differ between 88abc34 and e7dfd1ca. The claim lines are lease.rs:400 and state_lock.rs:419 on both. No other review covers this head. GitHub reports the pull request mergeable and behind main.

Before, on cec09eaa3cb9011290afd207944735433d768681, with temporary tests that assert the fixed contract, then restored:

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-cluster --lib -- \
  one_failed_heartbeat_does_not_fence_a_live_owner_issue_1067 \
  a_landed_beat_whose_ack_was_lost_refreshes_the_owner_issue_1067 \
  a_hung_first_beat_fences_before_a_successor_can_claim_issue_1067 \
  -- --test-threads=8

Result: 0 passed, 3 failed. Some(Lapsed) at 20s for one failed heartbeat. Some(Lapsed) at 20s for a landed beat whose answer was lost. The hung first beat left start waiting at 20s while the successor was Claimed.

After, the pull request's own lease tests on 88abc34:

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

Result before the temporary wedge test: 10 passed, 0 failed. With the temporary test added: 10 passed, 1 failed (same=Alive after 0ns, later=Alive after 0ns). The temporary test was removed. Those lease sources are unchanged at e7dfd1ca. e7dfd1ca was not recompiled.

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

Result on f5b939d3: 6 passed, 0 failed. state_lock.rs is unchanged through e7dfd1ca.

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-storage --lib -- a_fence_stops_admitted_requests_and_later_parts_issue_1067 -- --test-threads=8

Result on 88abc34: 1 passed. scoped.rs is unchanged at e7dfd1ca.

CI on f5b939d3 is green, including Test Workspace, both RustFS S3 shards, Azurite, GQT, and the DST pinned suite. Those jobs do not run the wedge test. I did not re-run CI on 88abc34 or e7dfd1ca. I did not run a live S3 server or an injected HTTP 503.

A .gqt case cannot express this contract. The defect is lock ownership under lost acknowledgements, not rows, counts, column types, or query errors.

Inline comments

crates/omnigraph-cluster/src/lease.rs:400 — This returns Alive for every existing superseded.json, before it reads the body and before it waits out the silence. The own-claim check below runs only when write_text_if_absent returns Ok(false) in this same call. A claim that landed, and whose lock replace did not finish, makes the next watch_and_claim return Alive at once. A temporary test on 88abc34 observed same=Alive after 0ns, later=Alive after 0ns for the crashed successor's own id and for a different id. These lines are the same at e7dfd1ca. A later successor must be able to finish the if-match while lock.json is still the version it read. A claim whose successor never became the lock owner must not block that.

crates/omnigraph-cluster/src/state_lock.rs:419 — The claim already exists when this if-match runs. If this call returns an error and does not apply, or the process stops before it, take_over leaves superseded.json in place and leaves lock.json unchanged. The owner fences on the claim and exits without a release. The next server sees the claim at lease.rs:400 and reports state_lock_held forever. Delete is the wrong cleanup. A second successor can if-match this same version, and that if-match is already the single winner. On a failed replace, leave the lock version as the thing the next start can still take.

…ped claim

A successor created `superseded.json`, then replaced `lock.json` by
if-match. If it stopped between the two, every later start found the claim,
reported the lock held at once and never watched the silence, so no server
could take the lock and an operator had to unlock it.

The claim now only fences an owner that is somehow still running. Each
successor waits out its own lease and a half of silence, makes sure the
claim exists (one already there changes nothing), and replaces `lock.json`
by if-match on the version it read. That if-match alone decides the next
owner, so a stopped successor leaves the lock for the next start.
@ragnorc

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. The wedge was real. It is fixed in 459c383a, with the RFC side in #1093 at 5bc17854.

The claim elects nobody now.

  • watch_and_claim no longer returns Alive because superseded.json exists. Each successor waits out its own lease and a half of silence, then makes sure the claim exists. One already there, from this successor or another, changes nothing.
  • It returns Watch::Silent, which replaces Claimed. The successor then replaces lock.json by if-match on the version it first read.
  • That if-match alone decides the next owner: of two successors, or of a successor and the old owner's release, exactly one applies. A successor that stops between its claim and its if-match leaves lock.json at the dead owner's version, so the next start takes it over the same way.
  • The claim's one remaining job is to fence an owner that is somehow still running, at its next beat. The own-claim read-back is gone.
  • An empty listing after a seen beat still returns Alive. A takeover or a clean release removes the beats, and the if-match would lose anyway.

Tests, under the existing owners:

  • lease::tests::a_claim_left_by_a_stopped_successor_does_not_block_takeover_issue_1067: your scenario, under a name that states the contract. After a successor finds the owner silent and stops, both that successor's id and a different one find the owner silent again, each only after its own 1.5 leases. Your temporary test saw Alive after 0 ns for both.
  • lease::tests::a_successor_finds_a_silent_owner_after_a_lease_and_a_half_issue_1067, which replaces the one-winner claim test: two successors both find the owner silent, and the claim is in place.
  • state_lock::tests::a_takeover_survives_a_successor_that_stopped_after_its_claim_issue_1067: with a stopped successor's claim in place and lock.json unchanged, two racing starts each wait out their silence, and exactly one acquires.

Also since your review, from the RFC review of c8ba085b: a lease fence now fences the storage scope (88abc34c). StorageIoScope::fence refuses every later request, including the parts, completion and abort of an admitted multipart upload, and cancels every admitted request still running. That covers the retry and the multipart part you traced, which closing did not stop. A cancelled request's caller gets an ambiguous error, never the scope's definite refusal. scoped::tests::a_fence_stops_admitted_requests_and_later_parts_issue_1067 covers every mutation boundary.

Verification on 8acad43a (#1096 and main at d111c403 merged), with RUST_MIN_STACK=16777216, macOS arm64:

  • cargo fmt --all --check: clean.
  • Six-package suite (engine, server, cluster, core, storage, catalog, both failpoint features): 2770 passed, 0 failed, including all 17 lease::tests and state_lock::tests and the fence test.
  • DST, from crates/omnigraph-dst: 7 passed, including dst_successor_takes_over_a_dead_owners_lease_issue_1067 and dst_crash_state_enumeration_sampled.
  • Both workspace clippy graphs: clean.
  • Not run: live S3, or an injected HTTP 503 against a real store.

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation

Merge 8acad43a.

Why

A successor that stopped after it wrote superseded.json, and before it replaced lock.json, made every later start return state_lock_held at once. The dead owner's lock stayed. An operator had to unlock it by id.

What

The claim no longer chooses the next owner. Each successor waits a lease and a half of its own silence, then makes sure the claim exists. The if-match on the version of lock.json that the successor first read chooses the next owner.

How

watch_and_claim in crates/omnigraph-cluster/src/lease.rs returns Silent after that wait. A claim that is already present, from this successor or another, does not change the result. take_over in crates/omnigraph-cluster/src/state_lock.rs sends the if-match only after Silent. It deletes the old lease directory only after that if-match applies. Of two successors, or of a successor and the old owner's release, one if-match applies. The other refuses.

The claim still fences an owner that is running, at that owner's next beat. The owner also fences itself two thirds of a lease after its last acknowledged beat. That fence still closes the storage scope before the successor may claim.

Impact

A later start can finish a takeover that stopped between the claim and the replace. The wait is one lease and a half. That is 3 seconds at the minimum lease and 15 minutes at the maximum. Two servers cannot both hold the lock. A fresh claim does not skip the silence. A leftover claim does not add a wait that grows without a bound.

Earlier findings

The five blockers on cec09eaa, and the three optional notes from that review, stay resolved. The wedge on e7dfd1ca is resolved.

Blocking

None.

Optional

RFC 1093 at 5bc17854, section 7, still says a present claim proves this successor wrote it. The bullets under Successor say a claim from any successor changes nothing. This code follows those bullets. The older sentence can send a later edit back to the own-claim read that caused the wedge. The sentence is in #1093.

Tradeoff

Restart has to succeed after a crash in the claim step. That comes before a shorter claim. The claim only fences a running owner. One conditional update of lock.json, plus new heartbeat creates, remains the record that decides the owner. Two successors now both wait, and both send an if-match. That is one extra conditional put, and the loser refuses. Five more objects beside lock.json would add five places a crash can stick. This change removes one.

Validation

Reviewed head: 8acad43a86760ac6e29373396632c913528ce583. The pull request is open. No other review covers this head. Current main is 277478d84cf19159f43cb21f202b49a044f0185e.

A .gqt case cannot express cluster-lock ownership under a stopped claim. The owner is omnigraph-cluster lease.rs and state_lock.rs. This pull request includes both regressions.

Before, on e7dfd1cae0cd4e04f7d7435483df727367233153, with temporary tests that assert the fixed contract, then restored. Watch::Silent does not exist on that commit, so the acquire test expects Watch::Claimed.

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-cluster --lib --locked -- \
  a_claim_left_by_a_stopped_successor_does_not_block_takeover_issue_1067 \
  -- --exact --nocapture

Result: 0 passed, 1 failed. Panic: stopped=Alive after 0ns, later=Alive after 0ns.

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-cluster --lib --locked -- \
  a_takeover_survives_a_successor_that_stopped_after_its_claim_issue_1067 \
  -- --exact --nocapture

Result: 0 passed, 1 failed, in 3.01s. Panic: each start waits out its own silence, got 218.06µs.

After, on the reviewed head, rebuilt from that commit after removing a stale omnigraph-cluster binary that a shared build directory had kept:

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

Result: 17 passed, 0 failed, in 6.02s. cargo fmt --all --check is clean.

CI on this head is green, including Test Workspace, Lint (clippy), Format, GQ Logic Tests, the DST pinned suite, both RustFS shards, and Azurite. I did not re-run those jobs. I did not run a live S3 server or an injected HTTP 503. The acquire test uses the in-memory adapter, whose conditional update is object_store 0.13.2 InMemory::update under its write lock. The S3 path sends If-Match (PutMode::Update) and retries an HTTP 409 with that same tag. A retry after the tag has changed fails the precondition, so it does not create a second owner.

ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 21, 2026
Accepted on the maintainer's instruction after review approved the RFC,
ModernRelay#1096 and ModernRelay#1097. The owned server operations and engine settlement RFCs
get disposition notes for the parts this replaces: unknown storage
outcomes no longer close admission, ambiguous storage outcomes are fenced
by re-issue and the version key instead of fail-stop, and a server's S3
lock is leased.

Section 7 also says, per the approving review, that a claim proves
nothing about its writer and that the owner's timer, not the claim, is
the fence.
@ragnorc
ragnorc added this pull request to the merge queue Oct 21, 2026
Merged via the queue into ModernRelay:main with commit 888223b Oct 21, 2026
32 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.

2 participants