Skip to content

test: fix integration flakiness from shared-etcd cross-contamination - #312

Merged
shubhamranjan merged 1 commit into
mainfrom
fix/watch-reconnect-robustness
Jul 7, 2026
Merged

test: fix integration flakiness from shared-etcd cross-contamination#312
shubhamranjan merged 1 commit into
mainfrom
fix/watch-reconnect-robustness

Conversation

@shubhamranjan

@shubhamranjan shubhamranjan commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Problem

The nightly Beast (flakiness) workflow kept failing WatchResilienceTests.Watch_ShouldRecover_AfterServerRestart with Assert.NotEmpty() Failure: Collection was empty — even after the revision-resume fix in #310.

Root cause: test cross-contamination on the single shared etcd1

Not the reconnect logic. Confirmed by experiment:

  1. Parallel collections + shared auth state. xUnit runs different test collections in parallel, and there was no assembly-wide guard. AuthClientIntegrationTests enables/disables cluster-wide auth on etcd1. Any other test hitting etcd1 unauthenticated during that window fails with etcdserver: user name is empty — so the resilience watch receives no events → empty collection. (Verified: manually enabling auth makes the test fail immediately.)
  2. Resilience tests disrupt the shared server. WatchResilienceTests pause/restart their etcd, breaking whatever test runs next on etcd1 — parallel or serial (serializing alone surfaced EtcdClientIntegrationTests HTTP/2 failures against a paused etcd1).

Fix

  • xunit.runner.json with parallelizeTestCollections: false — collections run sequentially, so auth toggling can't overlap other etcd1 tests.
  • Dedicated etcd-resilience container (single node, port 2409) for WatchResilienceTests, so pausing/restarting it never touches the shared etcd1 cluster.

Verification

  • Full integration suite: 57/57 green (62s).
  • Both resilience tests pass against the dedicated instance; restarting it leaves etcd1 untouched.

Follow-up to #310 — together these resolve the recurring nightly Beast failures.

The nightly beast kept failing Watch_ShouldRecover_AfterServerRestart with
'Collection was empty' even after the revision-resume fix (#310). Root cause was
test cross-contamination on the single shared etcd1, not the reconnect logic:

1. xUnit runs different test collections in PARALLEL and there was no assembly-wide
   guard. AuthClientIntegrationTests enables/disables cluster-wide auth on etcd1;
   any other test hitting etcd1 unauthenticated during that window fails with
   'etcdserver: user name is empty' -> the watch delivers no events -> empty.
2. WatchResilienceTests pause/restart their etcd server, disrupting every other
   test that shares it (parallel or serial).

Fix:
- Add xunit.runner.json with parallelizeTestCollections=false so collections run
  sequentially and auth toggling can't overlap other etcd1 tests.
- Give WatchResilienceTests a dedicated single-node etcd (etcd-resilience, port
  2409) so pausing/restarting it never affects the shared etcd1 cluster tests.

Verified: full integration suite 57/57 green; both resilience tests pass against
the dedicated instance.
@github-actions github-actions Bot added the tests label Jul 7, 2026
@shubhamranjan
shubhamranjan merged commit 6fc1498 into main Jul 7, 2026
4 checks passed
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Summary
Generated on: 07/07/2026 - 20:27:12
Coverage date: 07/07/2026 - 20:26:08 - 07/07/2026 - 20:27:10
Parser: MultiReport (2x Cobertura)
Assemblies: 1
Classes: 14
Files: 23
Line coverage: 94.5%
Covered lines: 1934
Uncovered lines: 112
Coverable lines: 2046
Total lines: 5359
Branch coverage: 79.5% (296 of 372)
Covered branches: 296
Total branches: 372
Method coverage: 96.7% (267 of 276)
Full method coverage: 85.5% (236 of 276)
Covered methods: 267
Fully covered methods: 236
Total methods: 276

dotnet-etcd 94.5%
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 89.2%
dotnet_etcd.WatchEvent 100%
dotnet_etcd.WatchManager 92.9%

@shubhamranjan
shubhamranjan deleted the fix/watch-reconnect-robustness branch July 13, 2026 14:41
shubhamranjan added a commit that referenced this pull request Jul 13, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant