Skip to content

MOVING handoff: hold new work back while draining, so the recycle happens within a round trip instead of at the deadline - #3335

Open
mgravell wants to merge 24 commits into
mainfrom
marc/moving-handoff-quiesce
Open

mgravell wants to merge 24 commits into
mainfrom
marc/moving-handoff-quiesce

Conversation

@mgravell

@mgravell mgravell commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The MoveTo and Recycle handoff paths drained by waiting for "no caller work" before replacing the connections. Under steady traffic that never happens: there is always a command in flight, so the drain waited out the entire announced window and recycled at the deadline, failing whatever was in flight at that instant and leaving no time to establish the replacement.

Measured live on a Redis Enterprise endpoint rebind (single proxy policy, maintMovingEndpointType=Auto) with about 450 ops/s on the connection:

event before after
MOVING received +5.8s +5.8s
MoveTo decided +6.6s +6.6s
connections replaced +20.9s +6.7s
ConnectionRestored +20.9s +6.8s
failed commands 8 0
"did not establish a replacement within the announced 15000ms" yes no

The timing matched the code to the tenth of a second: 15s window, 0.8s jitter, DrainThenRecycleAsync waiting out the remaining 14.2s.

What the handoff now does

Hold. When the drain starts, each bridge holds new caller work back from the connection about to be retired: single messages, batches, sync and async all go to the backlog instead of the socket, whatever the backlog policy, because the connection is live and the message would otherwise have been written to a socket the server is about to close. The backlog processor does not flush while the hold is unresolved. Internal traffic (keep-alives, probes) still goes to the old connection, and QUIT is never queued.

Drain. The drain waits only for caller commands already written to the connection, not for the backlog, so under load it completes within a round trip. The wait is capped at half the relaxed timeout, never more than half of what is left of the window and never more than maintRelaxedWindowMax (the relaxation the drain depends on), with a one-second floor so that a relaxed timeout of zero (relaxation off) does not disable the drain. A completion notification for some other operation arriving mid-handoff can lengthen the relaxed window but not shorten it below what the drain extended. With under a second of window left there is no drain worth having: the connections are replaced at once, and the hold lasts only that instant. Past the cap the connection is replaced regardless, and whatever is still in flight fails as the server's close would have failed it. A blocking command that fits inside the cap completes; one that does not (a 12s BLPOP in a 5s cap) fails at the cap rather than at the window's end.

Retire. The replacement happens under the bridge's write lock, and only to the connection the hold began on: a replacement established meanwhile is never touched. Under the lock only the marking happens (RetiredForHandoff); the report and dispose follow once the lock is released, so writers are not stalled behind event handlers and socket shutdown. A writer that reaches the lock to find its connection retired requeues its message for the replacement, keeping its write time (the budget it has spent still counts); internal and backlog-bypassing messages are failed as having no connection. If the lock cannot be had at all inside the cap plus a one-second grace (a writer wedged on it), the connection is retired without the lock as a last resort, which is what every recycle did before handoffs existed. Both bridges are drained, each with its own loop and its own lock deadline, because the blast radius of a MOVING is the node, not the connection that was told.

One phase per handoff. Each bridge keeps one Handoff object per handoff: a phase advanced in one direction by compare-exchange (Holding while the target is drained, AwaitingReplacement once it is gone, Flushing while held work is written to the replacement, Done), the facts fixed when it began (the target, the retiring address, the successor if one was named, whether an address identifies a node), and its own deadline (the hold, the rest of the window and one connect attempt), read on every question put to it so a passed deadline is noticed while fresh. Whether new work is held, whether the backlog may flush, whether a failure is the handoff's own and whether an enqueue is covered are all answered from the phase; nothing about a connection is inferred from it, since what a connection is (the target, made under the successor pin, established) is recorded on the connection when it happens.

Recognising the replacement. After the retire, whatever connection becomes current is classified as a bona fide replacement, doomed, or not yet classifiable:

  • the connection the hold began on (or one adopted as such) is doomed: the server will close it;
  • when the server named a successor, reconnection is pinned to it for the rest of the window (not cleared when the first bridge establishes, so the second bridge's reconnect is not sent back to stale DNS), and a connection records at connect time which pin it was dialled through: a connection dialled through this handoff's successor is the replacement, even at the same address as the node being retired (a proxy restarted in place);
  • otherwise, where the handoff has itself seen a second address (a successor at a different address, or DNS moving to a new address) and there is no tunnel in the way, the socket's remote address is compared with the address of the node being retired: a reconnect that landed back on it is doomed, and is retired in its turn by the drain, so the held work does not get written to a connection about to be closed;
  • where no second address was ever involved (a tunnel, a fixed address in front of the cluster, a successor at the same address), the remote address identifies nothing, and only time tells: once the target is gone, what is current is the replacement;
  • a connection whose address cannot be read yet, or that nothing can be compared against, is unknown, and unknown always defers to a later pass; a verdict of replacement needs positive evidence, never a default.

A stale pin confers nothing: the pin is matched against the successor of the handoff doing the judging, and a MOVING that names no successor drops any earlier pin before draining. The pin itself (target, expiry, and the generation of the MOVING that set it) is one state under the maintenance lock: a handoff's late decision cannot clear or overwrite a pin a later MOVING set, and the heartbeat's expiry check cannot null a fresh one. The judgement is not a one-shot: it runs at establishment, when the drain loop ends, and from every bridge heartbeat, so a reconnect that landed back on the retiring node after the drain loop ended is still adopted and retired (adoption is a compare-exchange, the retire outside the loop is one at a time and idempotent under the lock, and a failed attempt is retried on the next beat), the target itself is retired again if the loop's retire never got the lock or faulted, a connection whose address could not be read at establishment is judged again a second later, and a doomed landing while the held work is already flushing (the replacement dropped, stale DNS sent the reconnect back) puts the hold back in force before it is retired. Held work flushes to the replacement only once it is a known replacement; until then the hold stays in force.

Protection and cover. While a handoff is not Done, a FailFast backlog policy does not abandon the held work for a failure that is the handoff's own: the retired connection, a doomed reconnect, or a replacement attempt that never completed its handshake. A replacement that was fully established and then failed is an ordinary failure, and the policy applies as usual. The handoff is done when the held work has been written to an established replacement, or at its deadline (the hold, the rest of the window and one connect attempt).

Every caller command that enters the backlog during a handoff is held: diverted off the live connection, queued while the bridge reconnects, or parked behind held work being flushed (internal traffic such as keep-alives is not); so is caller work already queued when the hold begins, since the hold delays it the same way. A held command is stamped with the deadline of the handoff that held it, as an absolute tick, so it cannot time out before then for as long as it lives, whatever the relaxation setting and whatever happens to the handoff afterwards (a transaction's inner commands inherit it with the write time, and an internal member of a held batch is covered with the batch); and no longer than that, since a later handoff stamps its own deadline rather than inheriting an earlier, longer one. Only held commands get this; an ordinary command on the same server, including one issued by an application that has turned relaxation off and runs short timeouts, keeps its configured timeout. Because every held command in a handoff shares the deadline and ordinary commands can only be enqueued once the handoff is done, a later backlog message does not time out before an earlier one, so the backlog sweep's head-of-line rule holds (a requeued writer with an older write time is the documented exception: late by a bounded amount, never early); the in-flight sweep on the replacement looks past a held head rather than stopping at it, so ordinary commands written behind one keep their own timeouts. Sync callers re-read their budget after every wait, so a relaxation or cover that begins after they park is honoured; they wake in one-second slices only while the budget can shrink (a window is open, or a handoff is in flight), deciding that per wait, so a server with nothing in progress costs them nothing. Where relaxation is on, the relaxed window is also extended by the cap plus the relaxed timeout, clamped to MaintenanceRelaxedWindowMax.

Bounds. An announced window is capped at one hour before anything is derived from it. Every tick-count deadline in the maintenance code (relaxed window, successor pin, handoff deadline, held-command cover) goes through one TickDeadline helper: zero means unset, extension is monotonic, a deadline is in force only within one span of now (so the residue of one that passed long ago can neither revive when the tick count wraps nor win against a fresh extension), and a passed deadline is otherwise kept as the record of when it passed. The successor pin is read on every server heartbeat so its expiry is noticed while fresh.

Things it considers (and how)

  • Steady load: the drain cannot wait for an idle moment, so it waits for written work only; held work is written to the replacement in approximately the order issued.
  • Blocking commands in flight: the cap is half the relaxed timeout, the one setting an application already uses to say how patient it is during maintenance.
  • Relaxation disabled, short timeouts: the drain still runs (1s floor) and the held work is still covered; everything else keeps its short timeout.
  • FailFast backlog policy: honoured for genuine failures, not for the handoff's own, for exactly the duration of the handoff.
  • Target dies by itself during the hold (window end, a blip): nothing is retired; the hold stays until a bona fide replacement is current.
  • Reconnect landing back on the retiring node: classified doomed, re-retired by the drain, never written to.
  • Second handoff while the first is still reconnecting or flushing: a new handoff object, the deadline extended rather than reset; the earlier handoff's queued work keeps its stamped cover and is flushed with the new handoff's.
  • Tunnels and front addresses: the address test is switched off where it would classify every connection as doomed, and a tunnel never records a successor dial it did not make.
  • A writer starved on the lock across the retire: requeued, keeps its write time; under bypassBacklog or for internal traffic, failed as no-connection (InternalFailure: "Output pipe not initialized" when a write races connection teardown #3167's closure-vs-handoff distinction kept).
  • A wedged write lock: lockless retire as the last resort, after cap plus grace.
  • One missed instant: no phase depends on a single callback or poll; the heartbeat re-judges the current connection while a handoff is in progress.
  • Back-to-back handoffs: a new hold is published before the earlier handoff is finished, so no instant has no hold in force; the earlier handoff's held work belongs to the backlog, keeps its stamped cover and flushes with the new handoff's; a pin from the earlier handoff confers nothing on the later one.
  • A lock wedge or a fault during the retire: the heartbeat retries the retire of a target that is still current once the drain loop is over, one attempt at a time; marking a connection retired is a compare-exchange, so the lockless last resort and a locked retire tear it down once between them.
  • A second MOVING inside the first window: one decision-and-drain runs at a time per server (two drains on the same bridges would end each other's holds), but a MOVING arriving while one is in flight is not dropped: a named successor updates the pin, and the drain in flight replaces the connections, which then dial it. "A handoff exists", for the relaxed window's shorten guard and the sync waiters' slicing, is the bridges' phase, not the drain's claim.
  • The flush's last message: a message the processor has dequeued but not yet written still counts as held (the termination check takes the backlog lock, under which the move from queue to hand happens), so the handoff is not declared done around it.
  • Hostile or absurd windows: 1h cap, clamped deadlines, no overflow into a delay or a cancellation source.
  • Dispose mid-handoff: nothing left to retire, protection cleared, backlog abandoned with ObjectDisposedException as before.
  • Public API: unchanged; docs/ServerMaintenanceEvent.md describes the hold, the cap and the per-held-command cover.

Tests

  • MovingUnderSteadyLoadRecyclesPromptlyAndFailsNothing: steady load against the in-process server, MOVING with a 10s window and a named successor; the recycle follows within a fraction of the window and nothing fails. Fails on main (recycle at ~10s).
  • MovingUnderSteadyLoadWithRelaxationDisabledStillDrains: relaxed timeout zero.
  • MovingUnderSteadyLoadWithShortTimeoutsAndRelaxationOffKeepsHeldWork: 1s timeouts, relaxation off, slow server: held work is covered, nothing fails.
  • MovingWaitsForSlowCommandsThatFitInsideTheDrainCap: a 3s command in flight completes before the recycle.
  • MovingWithBlockingCommandsInFlightRecyclesAtTheDrainCap: a 12s command in flight; the recycle comes at the cap, not at the window's end.
  • Maintenance*, *Backlog*, *Connect*, *Timeout*, Issue3167Tests, SanityChecks pass against the docker topology; full net10.0 suite 6879 total, 0 failed on the current head. FailoverTests.SubscriptionsSurvivePrimarySwitchAsync is flaky on unmodified main on this machine and is excluded from that judgement.
  • Live, against RS 8.0.22 through the fault injector: the table above, repeated across the rebind, migrate, failover and maintenance-mode faults, with the handoff within 0.1 to 0.9s of the MOVING and no failed commands.

Review history

Twenty-three rounds of self-review were addressed in the commits on this branch; actioned comments are removed to keep the PR readable. Round 17 asked for the protection flags to be collapsed into one explicit per-handoff state, which is the design described above; round 18 added the periodic re-judgement; round 19 moved the deadline onto the handoff, gave the retire a second chance, and closed the last tick-wrap hazards; round 20 was edges only (same-address successors, the in-flight sweep, enqueue stamping, retire and flush races, the second-MOVING guard); round 21 closed the seams those left (the guard's release, the pre-hold queue's cover, per-wait slicing, the locked flush check, no default verdict); round 22 made doomed mean doomed in every phase and split "a handoff exists" from the drain's one-at-a-time claim; round 23 carried the cover into transactions and held batches and made the pin one generation-checked state.

…pens within a round trip instead of at the deadline

The MoveTo/Recycle paths drained by waiting for "no caller work" before
replacing the connections. Under steady traffic that never happens: there is
always a command in flight, so the drain waited out the entire announced
window and recycled at the deadline, failing whatever was in flight at that
instant and leaving no time to establish the replacement. Measured live on a
Redis Enterprise endpoint rebind with ~450 ops/s on the connection: MOVING at
+5.8s, MoveTo decided at +6.6s, connections replaced at +20.9s against a 15s
window, 8 failed commands, and the "did not establish a replacement within
the announced window" warning.

The bridge now has a quiesce mode for the duration of the drain: new caller
messages (single, batch, sync and async) go to the backlog instead of the
socket, the backlog processor does not write while quiescing, and the drain
waits only for commands already written. Held work is written to the
replacement once it is established, as across any reconnect; a FailFast
backlog policy no longer abandons it for a MaintenanceHandoff, which is a
replacement we chose rather than a failure. Internal traffic (keep-alives)
is not held.

Same scenario with this change: MOVING +5.8s, handoff +6.7s, restored +6.8s,
0 failed, nothing outstanding at the recycle, no warning.

The new test runs a steady load against the in-process server and asserts
that the recycle follows the notification within a fraction of the window and
that no command fails; it fails on main (recycle at ~10s, the whole window)
and passes here.
…live caller work

Review follow-ups on the quiesce-then-drain change:

- The drain is capped at one second as well as by the window. A blocking or
  stuck command in flight would otherwise hold everyone else's work in the
  backlog for the rest of the window and still be failed at the end of it;
  past the cap the connections are replaced regardless, and whatever was
  still in flight fails as the server's close would have failed it.
- The recycle takes the bridge's write lock and re-checks for in-flight work
  under it, so a writer that passed the hold check before it was set cannot
  slip onto the old connection between the check and the recycle. A writer
  that reaches the lock after a handoff recycled its connection queues for
  the replacement instead of writing to a dead connection; only for the
  handoff (an ordinary closure is still reported as one, #3167), never for a
  message bypassing the backlog, and never for internal traffic.
- Held work goes through QueueOrFailMessage's queue branch, so QUIT is never
  queued and the duplication is gone; QUIT and internal traffic are not held
  at all, in the batch path as well as the single-message paths.
- Work is held only while the connection is still live; once it has dropped,
  the ordinary disconnected rules and the backlog policy apply.
- A held enqueue that lands just after the hold is released kicks the
  backlog processor itself rather than waiting for the heartbeat.
- The orphaned summary is back on CheckBacklogForTimeouts, and
  docs/ServerMaintenanceEvent.md describes the hold.

New test: slow commands in flight do not hold the drain past the cap, and
traffic resumes on the replacement.
…e lock, with a bounded wait; requeue raced writers at the head

Second round of review follow-ups:

- Each bridge records the connection current when the hold begins and only
  ever retires that instance (TryRetireQuiesceTargetAsync). A replacement
  established meanwhile is never touched however often the drain loop
  retries, which the previous pass could do while the other bridge was still
  draining; and if the target went away by itself there is nothing to retire.
- Every retirement happens under the write lock: the forced pass at the cap as
  well. The lock wait is bounded by a cancellation token the drain owns (the
  cap plus a second of grace) rather than the mutex timeout; if the lock
  cannot be had even then, the connection is left to the server's close rather
  than recycled around a writer.
- A writer that lost its connection to the handoff while waiting for the lock
  is requeued as held work (never failed under FailFast) at the head of the
  backlog, via a front queue every backlog operation drains first, so it keeps
  its place ahead of what the hold queued after it.
- The cap is half the relaxed timeout (five seconds by default) bounded by the
  window, and timeouts are relaxed for the hold, so a blocking command with a
  timeout up to the cap completes and nothing held is timed out by the hold
  itself. A fixed second failed commands the server's close would have let
  finish; the whole window held everyone else's work for one of them.
- Each bridge is released in its own try/finally and the processor wake is
  guarded, so a bridge disposed mid-handoff cannot leave the other quiesced.
- The two unused in-flight predicates are gone; the requeue check is one
  helper at the three lock sites.

Tests: a slow command that fits inside the cap is answered and the recycle
follows it; a longer one is failed at the cap and traffic resumes on the
replacement; the steady-load test is unchanged.
…ly at the next hold, per-bridge release, cap floor

Third round of review follow-ups:

- BacklogRequeueFront takes lock(_backlog) around the head insert. The timeout
  sweep peeks, then dequeues and compares under that lock; an unlocked insert
  between the two handed it a different message, which it treats as a
  threading bug, and the dequeued message was dropped uncompleted.
- _recycledForHandoff is cleared when the next hold begins, not when the
  replacement establishes: the write lock is not fair, so a writer parked on
  it can be bypassed by the backlog processor long enough for the replacement
  to come up, and clearing then made it write to the disposed connection and
  tear down the healthy replacement.
- DrainCap has a one-second floor, so a relaxed timeout of zero (relaxation
  disabled) does not force the recycle with commands still in flight.
- Each bridge is released as its own retirement resolves, not when both have;
  the interactive bridge usually drains in a round trip and must not hold work
  while the subscription bridge takes longer. EndQuiesce is idempotent and
  kicks the processor unconditionally on the transition, closing the
  store-load race with a concurrent enqueuer.
- The drain works on snapshots of the bridge fields, and the mutex take and
  release tolerate disposal mid-handoff.
- A batch carrying a QUIT is not held at all, so the hold decision and the
  per-message action agree.
- The lockDeadline comment no longer claims the mutex timeout plays no part.

New test: the steady-load handoff with maintRelaxedTimeout=0.
…work's timeout, budget from what is left

Fourth round of review follow-ups:

- A connection failure during the hold no longer abandons the held work under
  a FailFast backlog policy: that backlog is work diverted off a live
  connection, which would have been written and answered, so it is left for
  the replacement to flush. Previously only the handoff's own recycle was
  exempt.
- The hold imposes its own floor on the effective timeout for its duration
  plus the reconnect (the cap, the lock grace and the connect timeout),
  independent of relaxation: with maintRelaxedTimeout=0 and a sub-second
  configured timeout, the one-second drain floor would otherwise have timed
  out exactly the work the hold protects.
- The front requeue roots the multiplexer on the empty-to-non-empty
  transition, as the ordinary enqueue does.
- The drain budget is what is left of the window after the decision has
  spent its share polling DNS, not the window minus jitter; with less than
  the floor left there is no hold, and the connections are replaced at once
  (still under the lock) so the replacement gets what remains.
- The handoff marker is a short ring of the last four retired connections,
  never cleared, so a writer starved across back-to-back MOVINGs still
  recognises its connection.
- Abandoning the backlog dequeues under the dequeuers' lock; a quiesced
  backlog pass returns before contending for the write lock; the processor
  start's wake tolerates disposal.
- One hold predicate, shared by the single-message gates and the batch gate,
  which makes one pass over the batch.
- The docs and the DrainCap remarks now agree with the clamp: the window
  bounds the hold because the server closes the socket when it ends.
…window, exact retired marker

Fifth round of review follow-ups:

- The hold cover spans the rest of the window plus one connect attempt, not
  one connect attempt alone: a MoveTo pins reconnection to the named
  successor until the window ends, and a successor that does not answer is
  tried until then. With relaxation disabled, held work could outlive the
  cover and be swept.
- The drain cap is also bounded by half of what is left of the window, so a
  relaxed timeout of two windows or more cannot turn the hold into a
  full-window stall that recycles at the deadline with work in flight.
- A disposed wake in KickBacklogProcessor still starts the processor, whose
  disposed-drain is the only thing that fails what is left once the heartbeat
  has stopped.
- The hold is keyed on the quiesce target still being the current connection,
  not on IsConnected: once the target is gone, by the drain or by itself,
  new work and the backlog flow to whatever is current.
- The retired-for-handoff marker is a flag on the connection itself, exact
  and holding nothing, in place of the four-slot ring.
- The finalizer abandons the backlog without the dequeuers' lock; everything
  else keeps taking it.
- The steady-load tests' resumption poll is wider, with a note that the
  in-process tunnel never dials the successor.
…, cover ends on establishment, teardown outside the lock

Sixth round of review follow-ups:

- A handoff-pending marker, set alongside the hold cover and cleared when a
  connection is fully established (or by the cover's deadline), keeps a
  FailFast policy from abandoning held work when a connect attempt on the way
  to the replacement fails. The hold alone covered the hold, not the reconnect
  it exists for.
- Each bridge is retired by its own loop with its own lock deadline, run side
  by side, so a writer wedged on one bridge's lock cannot spend the other's
  time and leave its idle connection to the server's close.
- The hold cover is cleared when the interactive connection establishes; the
  deadline is only the bound for the case where it never does. A short
  configured timeout with relaxation off regains hang detection the moment
  the replacement is up, instead of after the whole window.
- Under the write lock the retire now only marks the connection; the report
  and dispose follow the release, so event handlers and socket shutdown no
  longer stall every writer and the replacement's handshake. The retired flag
  on the connection is what keeps writers and the backlog processor off it in
  the gap.
- The front queue's comments say what it orders and what it does not.
- The RetiredForHandoff doc sits on its own member again.
- The docs say the floor wins over the half-window bound under two seconds.
- No kick helper: EndQuiesce and the two held-enqueue paths pair a full fence
  with their flag/queue accesses, so neither side misses the other and no
  processor is started for an empty backlog.
- The cap tests assert against jitter-aware bounds.
… a retired connection, FailFast exemption scoped, recycle path retires what is current

Seventh round of review follow-ups:

- The hold cover is cleared when both the interactive and the subscription
  connection are established (or the subscription is not required), not when
  the interactive one alone is: held subscribes must not be swept while the
  subscription bridge is still reconnecting.
- A writer that reaches the lock with a retired snapshot never writes to it,
  internal and backlog-bypassing messages included: those fail as having no
  connection instead of faulting the write mid-teardown and tearing down the
  bridge; a caller's message is requeued as before.
- The handoff-pending exemption from a FailFast abandon applies only to the
  handoff's own failures: the retired connection, or a replacement attempt
  that never got as far as a working connection. An established replacement
  that then fails is an ordinary failure. The marker is cleared when the
  handoff gives up on a bridge or finds nothing of its own to replace.
- On the no-successor paths, a target that went away by itself during the
  hold is replaced by whatever is current, since the ordinary reconnect may
  have landed back on the node being retired; with a successor pinned the
  reconnect owns what happens next, as before.
- The handoff tests gate on the slow command being observed in flight rather
  than on a fixed delay, and their time bounds carry more slack.
- The docs say "approximately in the order they were issued".
- One tick-sentinel helper (ServerEndPoint.NudgeFromZeroTicks) and one scan
  over both backlog queues.
…cover

Eighth round of review follow-ups. The protection a handoff raises - the
per-bridge shield against a FailFast policy abandoning held work, and the
server's floor on the effective timeout - now shares a single lifecycle:
raised in BeginQuiesce for a stated span, lowered only when a replacement
connection fully establishes on that bridge, when the handoff gives up on
it, or when the span runs out. The server's cover follows the bridges: it
stands down when no bridge still has its protection up.

- A target that went away by itself during the hold no longer stands the
  shield down: a replacement reconnect is in flight, and its first failure
  would otherwise have abandoned the held backlog.
- The shield covers failures of the retired connection and of any connection
  that never completed its handshake, whatever the failure type; a reset
  mid-handshake reports as a socket failure like any other. A replacement
  that was fully established and then failed is ordinary.
- The cover is no longer cleared by the old subscription connection still
  being live while the interactive replacement comes up; it waits for every
  bridge that still has a retire or reconnect pending.
- When nothing recycles and nothing re-establishes, the cover stands down
  with the shield rather than waiting for its own deadline.
- The orphaned sentinel doc and the doubled summary are gone; the docs say
  that under a second of window there is no hold at all.
…dge, leave a replacement that landed elsewhere alone

Ninth round of review follow-ups:

- The second backlog queue is gone. A writer that lost its connection to the
  handoff is appended to the backlog like the held work; the head insert
  bought only a best effort at ordering, at the price of a head-mutation
  discipline across every backlog operation. The abandon path keeps the
  dequeuers' lock, which closes a pre-existing race with the timeout sweep.
- A bridge with no connection when the hold began lowers its protection at
  once, so a dead subscription bridge cannot keep the timeout floor up for
  the whole cover span while the interactive replacement is serving.
- On the no-successor paths, a replacement that demonstrably landed on a
  different node than the one being retired is left alone; only one that
  landed back on the retiring node is retired.
- The hold case speaks through the protection alone: the connection the hold
  is draining counts as the handoff's own, so the blanket "while quiescing"
  clause on the abandon path is gone and an established replacement failing
  during the hold is an ordinary failure, as the doc says.
- The cover comments and docs say the cover stands down when every bridge's
  replacement is up, which is what the code does.
- The drain-cap test zeroes the server latency as soon as MOVING is sent, so
  the replacement's handshake is not queued behind the slow replies.
… path, give-up keeps the shield for diverted work

Tenth round of review follow-ups:

- The landed-on address is read inside a try/catch: a socket still
  connecting throws on RemoteEndPoint, and a concurrent teardown throws too;
  either counts as unknown, and unknown is retired. Previously the throw
  escaped, dropped the shield and abandoned the handoff in the very race
  the branch exists for.
- A hold that began with no connection (the old one dropped during jitter
  or the DNS poll) now goes through the same target-gone logic on the
  no-successor paths, so a reconnect that landed back on the retiring node
  is retired instead of surviving to the window-end close. With no current
  connection at all there is nothing to retire or protect, and the
  protection lowers.
- Giving up on a bridge (the lock not obtained, or an exception) stops
  holding new work but keeps the protection for the work already diverted:
  it was queued off a live connection whatever the policy, on the promise of
  a replacement, and the server's close plus the reconnect after it are that
  replacement arriving late. The protection lowers on establishment or
  expiry, as always.
- The bridge-disposed path lowers the protection: no replacement is coming.
- The fence-then-wake idiom lives in one method opposite EndQuiesce.
- The drain-cap test uses one blocking command, and zeroes the server
  latency only once the recycle is observed: the server reads its latency
  when it executes a request, and zeroing it earlier raced the command the
  test needs to stay slow.
… not on where the handoff's flow stands

Eleventh round of review follow-ups:

- A connection records that it was ever the one a hold diverted work off
  (WasHandoffTarget). A failure is the handoff's own if the connection has
  that mark or never completed its handshake, whatever the handoff's flow has
  done since: retired by the drain, closed by the server at window end,
  dropped by itself, or left un-retired after a give-up. The three remaining
  FailFast regressions were each a flow-based clear leaking on one path; the
  fact-based test closes the class.
- A drain pass that finds no current connection no longer lowers the
  protection: work may have been diverted before the target went, and the
  next failed reconnect must not abandon it. Only a hold that never had a
  target, and so diverted nothing, lowers it.
- The target itself completing a handshake it was in the middle of when the
  hold began is not mistaken for the replacement, so the protection and the
  timeout cover stay up while the hold is active.
- An expired protection deadline and an expired cover are cleared on
  observation, as GetRelaxedRemaining does, so a tick count wrapping ~24.8
  days later cannot re-arm them.
- The docs scope "whatever the backlogPolicy" to the drain, and say what a
  FailFast policy does to new commands between the replacement and its
  handshake.
- The in-flight check is done lock-free first; the write lock is taken only
  for a pass that is likely to retire. The one-line requeue alias is inlined.
…t; one tick-deadline type

Twelfth round of review follow-ups:

- A connection is a bona fide replacement when it is not the one the hold
  began on and, unless reconnection is pinned to a named successor, has
  demonstrably landed somewhere other than the node being retired - by
  address and port, since Redis Enterprise rebinds to other ports on the
  same host. Only such a connection lowers the protection when it
  establishes, ends the hold on new work and receives the held backlog. A
  reconnect that landed back on the retiring node (no successor, DNS still
  stale) is neither target nor replacement: new work is held off it, the
  backlog does not flush to it, and the drain retires it in its turn.
- The protection deadline is monotonic: a hold beginning while an earlier
  handoff's protection is up extends it and remembers that older diverted
  work exists, so a hold with no target of its own does not clear it.
- TickDeadline holds the sentinel, nudge, monotonic-extend and clear-on-
  expiry invariants once; the relaxed window, the hold cover and the
  per-bridge protection all use it.
- The three handoff flags on the connection are volatile.
- A requeued message keeps its write time; it only forgets the connection it
  was headed for.
…ement; a hold that diverted nothing protects nothing

Thirteenth round of review follow-ups:

- The bona fide test gains the input it was missing: once the retire has
  resolved, whatever connection comes up next is the replacement wherever it
  landed, including the same address and port - the common case after an
  in-place proxy restart or with stale DNS - so the protection stands down
  on its establishment instead of outliving its deadline. Before the retire
  resolves, the landed-elsewhere test still tells a replacement from a
  reconnect that landed back on the retiring node. The answer is latched on
  the connection once true, so the hot path does not re-read the socket.
- A hold records whether it diverted anything (or inherited an earlier
  hold's diverted work); a drain pass that finds no connection lowers the
  protection only if nothing was diverted, whether or not the hold had a
  target.
- TickDeadline takes TimeSpans and clamps them, and the announced MOVING
  window is capped at an hour on entry, so an absurd window cannot overflow
  a delay, a cancellation source or a deadline into the past.
- The handoff's relaxation respects maintRelaxedWindowMax, as the
  notification path does.
- The retire's report and dispose run under try/finally: a throwing user
  handler neither leaks the connection nor makes the retire look undone.
- The hold cover extends rather than overwrites, per its documented
  lifecycle.
- QueueOrFailMessage is back to its original shape; the gates queue held
  work directly, a batch with one fence for all its members.
…ded-on test applies after the retire, a lockless retire as the last resort

Fourteenth round of review follow-ups:

- A connection that an earlier handoff latched as its replacement and that a
  later handoff now retires is a target, not a replacement: marking it a
  target clears the latch, and the target check comes first.
- With no successor pinned, the landed-on comparison applies after the retire
  as well as before it: a reconnect that landed back on the node being
  retired is not a replacement at any point. The hold keeps new work and the
  held backlog off it until a bona fide replacement is current or the
  protection expires, instead of flushing onto a socket the server is about
  to close; and the FailFast shield now covers any failure of a connection
  that is not a known, established replacement, which includes that one.
  The latch is taken only once a connection is established, so a
  still-connecting successor that fails still counts as the handoff's own.
- The hold cover is bounded by maintRelaxedWindowMax, as the relaxation is,
  while always spanning the drain itself and one connect attempt.
- When the write lock cannot be had for the whole of the drain's deadline,
  the target is retired without it as a last resort - what every recycle did
  before handoffs existed - rather than left to the server's window-end
  close, which would fail everything in flight to protect at most one wedged
  writer's message.
- The guarded remote-endpoint read lives on PhysicalConnection; the inherited-
  protection flag is a local.
…less; the cover applies to held commands only

Fifteenth round of review follow-ups:

- Behind a Tunnel, or when the configured endpoint is itself a fixed address
  in front of the cluster, every connection reports the intermediary's
  address, so comparing it with the retiring one would make the genuine
  replacement look like a return to the retiring node forever. In those
  topologies the discriminator is temporal: a connection that is not the
  target and came up after the retire resolved is the replacement.
- The hold's timeout cover applies to the commands the hold diverted (and to
  those it requeued), not to every command on the server: they carry a mark,
  and the backlog sweep, the in-flight sweep and the sync wait consult an
  overload that adds the cover for them alone. An application that has turned
  relaxation off keeps its short timeouts for everything else; a held command
  at the head of a sweep can delay, never lose, the one behind it. The cover
  still spans the drain and one connect attempt, since that is what the held
  commands need.
- The diverted flag is set before the enqueue, so a drain pass cannot see a
  held message with the flag still off.
- The socket's remote endpoint is cached on the connection once read.
- The handoff target's expiry is a TickDeadline too; it was the last
  hand-rolled tick deadline.
- The fence-pairing comments name WakeIfHoldReleased, which every held
  enqueue goes through, as the single enqueue-side site.
- The steady-load test cancels its workers on any exit.
…over as a property of held commands, trust validated at use

Sixteenth round of review follow-ups. The protection state machine's rules are
now stated once at the top of the classification section, and the code is
checked against them:

- Classification has three outcomes: replacement, doomed, unknown. Unknown
  (an address not yet readable; or, behind a tunnel, time not yet telling)
  defers to the next pass and is never retired, never branded a target, and
  never trusted.
- A successor pin is trusted only while it is still in force: the first
  bridge's replacement clears the pin, after which the other bridge's retry
  resolves the endpoint again and is classified by where it landed.
- Behind a tunnel or a fixed front address, the temporal fallback also
  accepts "the drain has let go": after EndQuiesce nothing else will arrive,
  so what establishes is the replacement, rather than holding until the
  protection expires.
- The timeout cover is a property of each held command for as long as it
  lives, no longer a window on the server, so it cannot be withdrawn between
  the replacement establishing and the held work being written to it; the
  FailFast shield likewise stands down only once the last held message has
  been flushed to the replacement.
- A message that parks in a backlog that is holding (it passed the gate just
  before the hold rose) is held too, with the same cover.
- The sync wait polls in one-second slices, so a relaxation or a cover that
  begins after a caller parked is noticed even when nothing pulses.
- One teardown routine for the locked and lockless retire paths.

The in-process test server can delay replies to one command only, so a test
can slow the workload without pushing the replacement's own handshake past a
short timeout; a new steady-load variant runs with relaxation off and a
timeout shorter than a held command's wait.
…ection flags

Rounds 13 to 17 of review each found a case where two of the handoff's flags disagreed. This replaces them
with one object per handoff on each bridge, carrying a phase advanced in one direction by compare-exchange
(Holding, AwaitingReplacement, Flushing, Done) and the facts fixed when it began (target, retiring address,
whether an address identifies a node), plus one monotonic deadline shared by successive handoffs. Every
handoff question - hold new work, flush the backlog, is this failure the handoff's own, is this enqueue
covered - is answered from the phase.

With that in place:
- the hold cover is stamped on each held message as the handoff deadline (an absolute tick), so it cannot be
  withdrawn after the replacement establishes and does not ratchet across handoffs; the server-level cover
  and the stand-down-after-flush flag go;
- everything entering the backlog during a handoff is held, in BacklogEnqueue alone, which also keeps the
  timeout sweeps' head-of-line rule sound without skipping;
- a connection records that it was made under the successor pin, which is kept until the window expires
  rather than cleared when the first bridge establishes, so the second bridge's reconnect is not sent back
  to stale DNS;
- a socket address identifies a node only when the handoff has itself seen a second address (a named
  successor, or DNS moving) and no tunnel is in the way; otherwise only time tells;
- classification no longer latches a verdict on ordinary establishment: it is only consulted while a handoff
  is in progress;
- sync waiters re-read their budget in slices only where maintenance notifications can change it;
- TickDeadline keeps a passed deadline as the record of when it passed, replacing the hand-rolled
  relaxed-ended mirror, and owns the single nudge-from-zero helper.
…drain's bounds

Round 18. The phase machine's transitions were driven only by the drain loop and by establishment, so one
missed instant could wedge a phase until the deadline. Every heartbeat now re-judges the current connection
against the handoff in progress: a bona fide replacement resolves the hold, and a reconnect that landed back on
the node being retired is adopted (once, by compare-exchange on the target) and retired in its turn, which
also covers the drain loop having ended on a transient null and an address that was unreadable at
establishment. The retire is idempotent under the lock.

Classification puts the address before the pin, so a connection dialled through a stale pin that still named
the retiring node is doomed; the via-pin fact is recorded as the address dialled and matched against the
successor of the handoff doing the judging; and a MOVING that names no successor drops any earlier pin. A
new hold explicitly finishes an earlier handoff still flushing, so nothing is left mid-phase.

A window-closed notification arriving mid-handoff can lengthen the relaxed window but not shorten it below
what the drain extended; the drain cap is bounded by the relaxation ceiling it depends on (never below the
floor); the head-of-line note on BacklogEnqueue states the requeue exception honestly; and the held message's
cover is a TickDeadline, extended through the one ExtendTo that TryExtendBy now uses too.
…he last two wrap hazards

Round 19. The handoff deadline moves onto the handoff object: each handoff stands for its own hold plus the rest
of its window and one connect attempt, held work is stamped with that, and the deadline is read on every question
put to a live handoff, so a passed one is noticed fresh and no stale residue is ever consulted by a later handoff.
BeginHold publishes the new handoff before finishing the previous one, so there is no instant with no hold in force.

The heartbeat's judgement now also retires the target itself when it is still current after the drain loop is
over (the lock never came, or the retire faulted), not only an adopted reconnect; the retire outside the loop is
one at a time per handoff and idempotent under the lock, so a stuck attempt is not piled on and a failed one is
retried on the next beat.

TickDeadline treats a deadline as in force only within one span of now, in both RemainingMilliseconds and
ExtendTo, so a wrapped tick count can neither revive a long-passed deadline nor let it win against a fresh
extension; the successor pin is read on every server heartbeat so its expiry is noticed while fresh. Sync
waiters slice only where the server has ever accepted the opt-in, has a window open, or has a handoff in flight.
The steady-load test helper's try body is indented.
…retire and stamp races, slicing only while a window is open

Round 20. A successor at the very address being retired (a proxy restarted in place) no longer makes the address
identify the node, and a connection dialled through this handoff's own pin is the replacement before the address
is consulted - the stale-pin case the address-first order served is covered by matching the pin against the
judging handoff's successor and by a no-successor MOVING dropping the pin. The written-queue sweep looks past a
held head rather than stopping at it, as the backlog's ordering argument never covered the replacement's queue.

BacklogEnqueue stamps from the same handoff that gated the message, and not for internal traffic. Marking a
connection retired is a compare-exchange, so a lockless last-resort retire and a locked one tear it down once
between them. The processor's in-hand message counts as queued until written, so a flush cannot be declared
finished around it. The one-at-a-time guard on a MOVING covers the decision and the drain, not the warning wait,
so a second MOVING inside the first window is acted on. Sync waiters slice only while a window is open or a
handoff is in flight; an extension is honoured by the re-read after the wait regardless. TickDeadline.cs gets
the repository's UTF-8 BOM.
…nd judgement needs evidence

Round 21. HandoffAsync releases its one-at-a-time claim exactly once, by whichever path still holds it, so a
second handoff's claim is not stomped by the first's finally. BeginHold stamps the work already queued when the
hold begins, since the hold delays it as it delays diverted work; BacklogEnqueue re-reads the handoff after the
enqueue, so the two sides pair and a concurrent enqueue is stamped by one of them. Sync waiters re-evaluate per
wait whether their budget can shrink. The flush's termination check takes the backlog lock, under which the
processor moves a message from the queue to its hand. A connection with no positive evidence either way is
unknown, never a replacement by default. The drain's polls settle the no-retire cases without the write lock.
The relaxation ceiling is stated once, in ClampToRelaxationCeiling.
…he phase, not the drain's claim

Round 22. A reconnect that lands back on the retiring node while the held work is flushing puts the hold back in
force (Flushing to AwaitingReplacement, before the connection reads as established) and is retired; the heartbeat
judges a Flushing handoff too. A tunnel no longer records a successor dial that never happened. The drain's
one-at-a-time claim keeps serialising the decision and the drain, but everything that asks whether a handoff
exists - the relaxed window's shorten guard, the sync waiters' slicing - now asks the bridges' phases
(HandoffLive); and a MOVING arriving while a drain is in flight updates the successor pin instead of being dropped.
A diverted message whose hold has gone by the time it is enqueued is ordinary work rather than stamped from a dead
handoff; the post-enqueue re-read rides on the total counter's interlocked increment, so a bridge that never saw a
handoff pays one null read. A finished handoff lets go of its target. The docs state the cover's floor.
@mgravell

mgravell commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review notes, round 23 (head ec4f853). Round 22's items landed — Flushing-phase judging, the tunnel dial fact, the _handoffInFlight split, Finish() clearing the target, the fence gate — and the reviewer re-verified the write-lock paths, hold gating/wake pairing, the sync-slice rewrite, and TickDeadline's wrap handling clean. Seven items:

  1. Transaction sub-commands inherit the write time but not the cover. InheritWriteTime (which exists for exactly this timeout class — 2.12.14+ / 3.4.5: transactions on idle connections are torn down as "Dead socket detected … with 2 timeouts" when replies are slower than one heartbeat #3292) copies _writeTickCount only, so a held transaction's inner commands reach the replacement with HeldForHandoff=false and an elapsed clock that includes the whole hold: the next heartbeat fails them. InheritWriteTime should carry _holdCover too (or a sibling InheritHoldCover called at the same site).

  2. A second MOVING's pin can be clobbered by the first handoff's late decision. Fix command #2 pins its successor while ConnectionFailed isn't raised when single server node goes down #1 is still polling DNS; ConnectionFailed isn't raised when single server node goes down #1's decision then resolves to Recycle and calls ClearHandoffTarget() (or overwrites with its own stale pin) — erasing Fix command #2's pin before any dial. The clear/set in the decision paths needs a generation or notification-identity check: only clear a pin you set.

  3. The heartbeat pin-expiry races SetHandoffTarget. The getter's expire-then-null was a connect-path-only race before; ExpireHandoffTargetIfDue makes it once-per-second, exactly at refresh moments — a freshly set pin can be nulled. CAS the pair (or expire via the same TickDeadline the setter writes, target-then-expiry ordered, null only when the observed target is still the expired one).

  4. An internal message riding a held batch is swept, not late. The processor won't flush while held, and TimeoutFor covers only HeldForHandoff messages, which internal calls deliberately never are — so a hold longer than the configured timeout gives the batch's internal member a TimeoutBeforeWrite failure, against the code's own "at worst reported late" claim. Either stamp internal members of held batches, or exempt internal messages from the sweep while a hold is active on the bridge.

  5. BacklogEnqueue's diverted parameter is dead and its XML doc asserts the opposite of the body (the stale round-21 contract vs the round-22 fix). Delete the parameter and the stale doc — both call sites pass a constant.

  6. Sub-second windows still hold. cap == 0 skips the drain but BeginHold still runs, so work is diverted during the immediate retire — the docs promise "no hold at all, replaced at once". Skip BeginHold when the cap is zero (the forced retire doesn't need the gate), or amend the doc.

  7. Test timing: the blocking-commands drain-cap test's 10s bound leaves ~2-3s over the mechanism's own worst case on a wedged in-process server — widen or gate on observed state.

Verdict: not this round — 1 is the sharpest (a held transaction is guaranteed-failed by its own rescue under short timeouts; the fix is one field in an existing inherit hook), 2 and 3 are the pin's last two races, 4 is narrow but a real wrong-error, 5–7 are hygiene. The reviewer's cleared list keeps growing; everything left is in the pin lifecycle and two stamping gaps.

…pin is one generation-checked state

Round 23. InheritWriteTime carries the parent's hold cover as well as its write time, so a held transaction's
inner commands are not failed by the sweep on the replacement for a clock that includes the hold. An internal
member of a held batch is covered with the batch, since the batch is held as a unit and the sweep must not fail
one member of it. The pin - target, expiry, and the generation of the MOVING that set it - is kept consistent
under the maintenance lock: a handoff's late decision cannot clear or overwrite a pin a later MOVING set, and the
heartbeat's expiry check cannot null a pin set a moment ago. BacklogEnqueue loses its dead parameter and its stale
contract. The docs and the drain comment say what a sub-second window does with the hold (it begins, for the
instant of the replacement, because the retire is built on it). The drain-cap test's bound sits between the
mechanism's worst case and the deadline it guards against.
@mgravell

mgravell commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Round 23 addressed in 21ed775.

  1. Transaction sub-commands: InheritWriteTime now carries the parent's hold cover (_holdCover.ExtendTo(parent._holdCover.Ticks)) along with the write time, at the same site, so a held transaction's inner commands are not failed by the first sweep on the replacement.
  2. Second MOVING's pin clobbered: the pin carries the generation of the MOVING that set it (_movingGeneration, incremented per notification acted on, including those that only note a successor while a drain is in flight). SetHandoffTarget and ClearHandoffTarget take the caller's generation and do nothing against a pin a later MOVING set; a handoff's late Recycle decision cannot erase the second notification's pin, and its late MoveTo cannot overwrite it.
  3. Expiry racing the setter: the pin's target, expiry and generation are read and written under _maintenanceSync (every touch is rare: a MOVING, a connect, a heartbeat), so the heartbeat's expire-then-null is atomic with the setter and a fresh pin cannot be nulled.
  4. Internal member of a held batch: EnqueueHeldBatchAndWakeIfReleased stamps internal members itself with the live handoff's deadline (zero, no live handoff, covers nothing); the batch is held as a unit, so it is covered as a unit. The doc on BacklogEnqueue names the exception.
  5. Dead parameter: diverted and its stale contract are gone; the body's actual rule is in the summary.
  6. Sub-second windows: amended the doc and the drain comment rather than skipping BeginHold. The retire is built on the hold (the target, the late writer's requeue, the cover for what the reconnect carries), so the hold begins, but with a zero cap it lasts only the instant of the replacement; the doc now says that instead of "no hold at all".
  7. Test timing: the bound is 12s (poll 14s), between the mechanism's worst case of about 7.5s (1s jitter cap, 5s cap, 1s grace, poll granularity) and the 15s deadline the test guards against, with the arithmetic in the comment.

Verification: Release build clean with analyzers; full net10.0 suite 6879 total, 1 failed (the known SubscriptionsSurvivePrimarySwitchAsync flake; one extra skip this run, ExceptionFactoryTests.MultipleEndpointsThrowConnectionException, an environment precondition unrelated to this code). Live against the RS cluster through the fault injector, demo built against this head at about 430 ops/s:

fault notifications handoff restored failed ops worst latency
endpoint rebind (MOVING) MOVING +5.8s +6.4s +6.5s 0 617ms
maintenance mode MIGRATING +5.1s, MIGRATED +13.8s, MOVING +18.6s +19.1s +19.1s 0 831ms

@mgravell

mgravell commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review notes, round 24 (head 21ed775). Round 23's items landed (cover inheritance for sub-commands, the pin generation, the heartbeat-expiry CAS, batch internal stamping, the dead parameter, the zero-cap skip). The reviewer verified the lock-release ordering, fence pairings, in-hand move, wrap handling, and sync slicing sound. Eight items — five of them are back-to-back-handoff seams in the newest fixes, one is a genuinely new topology hole:

  1. The post-fence re-stamp only fires when the first read found no handoff (!live), so a message stamped under still-live handoff A that races BeginHold(B) keeps A's shorter cover — CheckBacklogForTimeouts can fail it mid-hold-B. Re-stamp when the instance read after the fence differs, not only when the first read was empty.

  2. Null retiring endpoint + address mode = Unknown forever. When TryGetRemoteEndPoint() was null at MOVING receipt but the decision still sets addressIdentifiesNode=true, both the address test and the time-tells fallback are skipped — no reconnect can ever classify, and the hold runs to the deadline against a healthy replacement. An unreadable retiring address should demote to time-tells.

  3. Both bridges share the interactive connection's retiring endpoint. Under multi-A-record DNS, the subscription bridge's reconnect can land on the retiring node's other address and pass the "anywhere else is the replacement" test — held pub/sub work flushed onto the doomed node. Each bridge must capture its own connection's remote.

  4. The pin generation guard is bypassed once the newer pin expires (_handoffTarget is not null gates the check), letting an older MOVING's late decision re-pin superseded information. Compare generations unconditionally.

  5. A successor-less second MOVING during an in-flight handoff leaves the stale pin standing — the in-flight branch only traces. It should drop the pin (the newest notification implies the pinned node is itself retiring), mirroring what the non-in-flight path does.

  6. MarkHeldForHandoff uses extend-only ExtendTo, defeating the restamp-shorter rule the walk exists for: a message stamped by long-window A keeps its cover through shorter handoff B. The stamp wants set-exact (or extend-down-on-restamp) semantics — distinct from the deadline fields where extend-only is right.

  7. Fault attribution flips mid-handoff: _relaxedType is overwritten by a completion before the in-flight check that preserves the window, so handoff-covered timeouts report a finished operation. Move the write behind the same guard.

  8. Perf: TryFinishFlush → AnyHeldQueued is an O(n) locked walk per processor pass while flushing thousands of held messages — keep a held-and-unwritten counter (stamp++ / write--) for an O(1) termination test.

Verdict: not this round. 2 and 3 are the sharp ones (a full-deadline stall on a healthy replacement; doomed-flush under round-robin DNS — the latter is new information, not a regression of a fix). 1, 4, 5, 6 are all back-to-back-MOVING seams: worth building a dedicated two-MOVINGs-in-one-window test matrix (second MOVING with/without successor × first handoff in decision/drain/flush), since this scenario class has produced findings in five consecutive rounds and each fix is point-tested at best. 7–8 are small.

@mgravell

mgravell commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Focusing note — scope, exit criteria, and what stops being a bug

Twenty-four review rounds in, a step back before a twenty-fifth. The headline problem — a MOVING no longer fails commands at the window-end close — has been solved for some time: the core pipeline (gate/divert/retire/flush/sweep, the fence pairing, the lock discipline) has survived ten consecutive adversarial rounds untouched, and the single-handoff steady-load scenario has been finding-free since ~round 15. What the marginal round now finds is conjunctions of rarities (two MOVINGs in one window × pin expiry × a late DNS decision; round-robin A-records on the retiring node; 24.8-day tick wraps), and most of those findings are seams of the previous round's fixes — the loop is generating its own work, and each fix taxes the common path (fences, slicing, scans) to perfect a rare one.

So, drawing the line:

1. The guarantee this PR ships (to be stated in docs/ServerMaintenanceEvent.md and the PR body):

  • One in-flight handoff per endpoint, on a direct connection, is fully covered: in-flight work drains, diverted work is never failed by the hold itself, and it flushes to the replacement or fails within the documented bound (hold + rest of window + one connect attempt).
  • Back-to-back MOVINGs inside one window, tunnel/VIP/identity-less topologies, and multi-homed retiring nodes are best-effort: held work is never lost or abandoned contrary to policy, but it may wait longer than the single-handoff bound, and the hold may resolve conservatively (later rather than wrong). Several currently-open findings stop being bugs under this contract and become documented behavior.

2. Exit criteria instead of more rounds:

  • The open items that are regressions versus main in plausible deployments get fixed: the shared retiring endpoint across bridges (round 24 Fix: if calls Close or Dispose many times it'll throws a NullReferenceException #3 — round-robin DNS is common), the null-retiring-endpoint permanent Unknown (Fix command #2 — demote to time-tells), and the fault-attribution ordering (no scan method ? #7, one-line).
  • The back-to-back-MOVING class moves from review to tests: a matrix of (second MOVING with/without successor) × (first handoff in decision / drain / flush), asserting only the contract above — nothing lost, policy honored, bounded wait. Findings in that class have recurred for five rounds; an executable matrix is the cheaper detector and the regression net the next editor needs anyway.
  • The remaining round-24 items (the !live re-stamp gate, the generation guard, the stale pin, extend-only stamping) are fixed only where the matrix fails under the stated contract — otherwise they're recorded as known best-effort behavior.

3. One deliberate simplification to weigh before merging, not after: pure temporal classification — "the replacement is whatever fully establishes after the retire resolves" — would delete the endpoint-identity apparatus entirely (the retiring-address capture, the per-bridge comparison, the tunnel/VIP special-casing, the null-endpoint fallbacks), which has been the source of findings in roughly eight rounds. Cost: the hold releases slightly later in the one corner where address evidence would have exonerated a reconnect sooner. At this point less machinery beats more-correct machinery; if the matrix passes with the temporal rule alone, the identity code should go.

Perf items (per-enqueue fence gating, the O(1) held counter, sync-slice gating) stay on the list but as a post-merge tidy-up PR — they're not release-blocking and each touches hot paths that deserve their own review.

Net: fix three things, build one test matrix, decide one simplification, document the contract — then merge. No round 25 of the current shape.

This branch has not been deployed

No deployments
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