Skip to content

fix(watch): wait for etcd to acknowledge a watch before returning - #313

Merged
shubhamranjan merged 1 commit into
mainfrom
fix/watch-flakiness-and-resume-revision
Jul 13, 2026
Merged

fix(watch): wait for etcd to acknowledge a watch before returning#313
shubhamranjan merged 1 commit into
mainfrom
fix/watch-flakiness-and-resume-revision

Conversation

@shubhamranjan

@shubhamranjan shubhamranjan commented Jul 13, 2026

Copy link
Copy Markdown
Owner

The flake was not the reconnect — the watch was never registered

WatchResilienceTests failed its first assertion (Assert.NotEmpty() Failure: Collection was empty) roughly 1 iteration in 20 of the nightly Beast. That assertion runs before any pause or restart, so the reconnect/resume logic was never even reached.

The client never waited for etcd's Created acknowledgement. Watcher.CreateWatchAsync awaited only RequestStream.WriteAsync, which guarantees "the create request was written to the socket"not "etcd registered the watcher". etcd's own proto says so:

Since creating a watcher in etcd is not a synchronous operation …

Two consequences, each proven by a test that fails before this change:

  1. Watch() / WatchAsync() returned before the watch existed server-side, so a write issued immediately after could be applied first and its event never delivered.
  2. Worse: if the stream died before any response arrived — exactly what happens when a watch is opened against an etcd that is still restarting — NextRevision was still 0, so HandleConnectionFailure re-registered with StartRevision = 0, i.e. "watch from now". But "now" was already past the caller's write, so the event was lost permanently and silently. The revision-resume added in fix(watch): resume from last observed revision on reconnect (fixes nightly beast flake) #310 only engages once NextRevision > 0, so it never covered this window.

The fix

WatchAsync now awaits the Created ack before returning. If the stream dies while waiting, the reconnect re-sends the create and its ack completes the same wait — so a create lost to a dying stream is retried rather than silently downgraded to "from now".

Supporting fixes, each with its own regression test:

  • Register the watch before writing the create. The server can answer while WriteAsync is still in flight, and TrackResumeRevision dropped responses for watches not yet in _watches — losing the created revision.
  • Seed NextRevision from the caller's StartRevision (as etcd clientv3 does: nextRev := w.initReq.rev). A Created ack carries the current cluster revision, so without this a watch resuming from a checkpoint would skip its backlog.
  • Ignore the ack header for a replay create, for the same reason — otherwise a reconnect jumps past the events etcd is about to replay.
  • Run user callbacks off the receive loop, serialized on one chain. They ran inline, so a callback that started another watch deadlocked: the loop it blocked was the only thing that could deliver the new watch's ack.
  • Serialize writes to the duplex stream — gRPC allows only one pending write at a time.
  • Dispose the abandoned Watcher on reconnect. It was leaked, and a still-healthy stream went on delivering every event a second time.
  • Keep the 5s reconnect retry alive when a re-register fails, and surface failures from the sync overloads as RpcException rather than AggregateException.

⚠️ Behavior change (worth a release note)

Watch() / WatchAsync() now block until etcd acknowledges the watch (30s cap, or the caller's deadline) and can throw (RpcException with DeadlineExceeded / FailedPrecondition) where they previously returned instantly. This is the point of the change — it is what makes "the event I write after Watch() returns will be delivered" actually true — but it is a change in shape.

Anyone unit-testing against their own mock watch stream that never sends a Created response will now see a 30s timeout instead of an immediate return.

Test / CI fixes

  • start-etcd.sh now health-gates etcd-resilience (added in test: fix integration flakiness from shared-etcd cross-contamination #312 but never waited for).
  • WatchResilienceTests: bounded polling instead of fixed sleeps; events collected in a ConcurrentQueue (they were appended from the gRPC receive loop and read from the test thread with no synchronisation); the trace is dumped on failure so the next flake is diagnosable.
  • beast.sh now captures test stdout (--logger console;verbosity=detailed) — previously all 140 archived CI logs contained zero test diagnostics — and -f All no longer breaks under bash 3.2 (macOS).

Verification

Every behavioural fix was confirmed RED before / GREEN after by reverting each implementation individually (e.g. the backlog skip: Expected: 11, Actual: 100; the re-entrancy deadlock hangs the full 15s timeout).

  • Full suite 417/417 (357 unit + 57 integration + 3 new; 6 new watch tests total)
  • 25× full-suite beast, all green — ~10,400 test executions, zero WatchResilienceTests failures

WatchResilienceTests failed its FIRST assertion ("Collection was empty")
about once every 20 beast iterations. The reconnect logic was not at
fault: the watch was never registered at all.

dotnet-etcd never waited for etcd's Created acknowledgement. CreateWatchAsync
awaited only RequestStream.WriteAsync, which means "the create request was
written to the socket" -- not "etcd registered the watcher". etcd's own proto
says so: "Since creating a watcher in etcd is not a synchronous operation".

Two consequences, both proven by tests that fail before this change:

  * Watch() returned before the watch existed server-side, so a write issued
    immediately after could be applied first and its event never delivered.
  * If the stream died before ANY response arrived -- exactly what happens when
    a watch is opened against an etcd that is still restarting -- NextRevision
    was still 0, so HandleConnectionFailure re-registered with StartRevision=0,
    meaning "watch from now". But "now" was already past the caller's write, so
    the event was lost permanently and silently. The revision-resume added in
    #310 only engages once NextRevision > 0, so it never covered this window.

WatchAsync now awaits the Created ack before returning. If the stream dies
while waiting, the reconnect re-sends the create and its ack completes the same
wait, so a create lost to a dying stream is retried rather than silently
downgraded to "from now".

Supporting fixes, each with a regression test:

  * Register the watch before writing the create: the server can answer while
    WriteAsync is still in flight, and TrackResumeRevision dropped responses for
    watches not yet in _watches -- losing the created revision.
  * Seed NextRevision from the caller's StartRevision (as etcd clientv3 does with
    nextRev := initReq.rev). A Created ack carries the CURRENT cluster revision,
    so without this a watch resuming from a checkpoint would skip its backlog.
  * Ignore the ack header for a replay create, for the same reason.
  * Run user callbacks off the receive loop, serialized on one chain. They ran
    inline, so a callback that started another watch deadlocked: the loop it
    blocked was the only thing that could deliver the new watch's ack.
  * Serialize writes to the duplex stream (gRPC allows one pending write).
  * Dispose the abandoned Watcher on reconnect; it was leaked, and a still-healthy
    stream went on delivering every event a second time.
  * Keep the 5s reconnect retry alive when a re-register fails, and surface
    failures from the sync overloads as RpcException rather than AggregateException.

Tests: health-gate etcd-resilience (added in #312 but never waited for), poll
instead of sleeping on fixed timers, collect events in a ConcurrentQueue (they
were appended from the receive loop and read from the test thread), capture test
stdout in beast.sh, and fix beast.sh -f All under bash 3.2.

Full suite 417/417; 25x full-suite beast all green.
@github-actions github-actions Bot added the bug Something isn't working label Jul 13, 2026
@github-actions

Copy link
Copy Markdown

Summary
Generated on: 07/13/2026 - 17:45:39
Coverage date: 07/13/2026 - 17:44:42 - 07/13/2026 - 17:45:38
Parser: MultiReport (2x Cobertura)
Assemblies: 1
Classes: 14
Files: 23
Line coverage: 93.1%
Covered lines: 2031
Uncovered lines: 150
Coverable lines: 2181
Total lines: 5613
Branch coverage: 78.7% (318 of 404)
Covered branches: 318
Total branches: 404
Method coverage: 96.7% (272 of 281)
Full method coverage: 84.6% (238 of 281)
Covered methods: 272
Fully covered methods: 238
Total methods: 281

dotnet-etcd 93.1%
dotnet_etcd.AsyncDuplexStreamingCallAdapter<T1, T2> 100%
dotnet_etcd.AsyncStreamCallFactory<T1, T2> 100%
dotnet_etcd.AuthenticationHttpHandler 96.1%
dotnet_etcd.ConnectionStringParser 100%
dotnet_etcd.DependencyInjection.EtcdClientOptions 76.1%
dotnet_etcd.DependencyInjection.EtcdClientOptionsValidator 100%
dotnet_etcd.DependencyInjection.ServiceCollectionExtensions 85.5%
dotnet_etcd.EtcdClient 95.5%
dotnet_etcd.GrpcChannelFactory 100%
dotnet_etcd.helper.AsyncHelper 100%
dotnet_etcd.multiplexer.Connection 100%
dotnet_etcd.Watcher 86.3%
dotnet_etcd.WatchEvent 100%
dotnet_etcd.WatchManager 88.1%

@shubhamranjan
shubhamranjan merged commit 419d8fc into main Jul 13, 2026
5 checks passed
@shubhamranjan
shubhamranjan deleted the fix/watch-flakiness-and-resume-revision branch July 13, 2026 17:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant