NCBC-4298: Make the config streaming backoff actually back off - #182
NCBC-4298: Make the config streaming backoff actually back off#182davidkelly wants to merge 1 commit into
Conversation
FIT performer imagePublished: ghcr.io/couchbase/dotnet-fit-performer:NCBC-4298Run FIT locally against this PR: fit run preset <preset-name> --performer dotnet-fit-performer:NCBC-4298Or run it from the workflow here. Note Each push to this PR replaces the image and edits this comment. The image is deleted 7 days after the last push. |
There was a problem hiding this comment.
🟡 Changes recommended
Cancellation-token capture still races disposal before the background delegate starts.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds effective exponential backoff to prevent rapid retries when all configuration-streaming endpoints fail.
Changes:
- Starts retry delays at 100 ms and caps them at 10 seconds.
- Makes backoff cancellation-aware and adds deterministic tests.
- Reuses a captured cancellation token throughout the listener loop.
File summaries
| File | Description |
|---|---|
HttpStreamingConfigListener.cs |
Implements cancellable exponential backoff. |
HttpStreamingConfigListenerTests.cs |
Tests retry progression, continuation, and disposal. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
30a5896 to
2cefec7
Compare
|
Checked go as well, since the PR notes the java divergence as an open question. There is no cross-SDK convention to match — the three disagree completely:
So java is aggressive and exponential, go is a flat ten seconds with no ramp, and these values sit between them with a cap equal to go's entire delay. Any value we picked would disagree with someone, so I have left them as they are — they are the ones already in the file, and the defect was the The one genuine fork is behavioural rather than numeric, and it is the behaviour change called out above: does a round that successfully got a config still pay a delay? Java says yes, go says no, and this PR follows java. Matching go instead would mean waiting only when the round produced nothing, which would restore the previous .NET behaviour exactly while still fixing the failure case. I would leave it — 100 ms on a stream reconnect is noise — but it is a real choice rather than something to inherit by accident. ( |
2cefec7 to
0480ba1
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Immediate disposal can cancel the scheduled background task and cause DisposeAsync to throw.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
Motivation
==========
HttpStreamingConfigListener tries every management node in turn, and
when all of them have failed it waits before starting the round again -
with a delay meant to grow to 10s, explicitly so that a total failure
does not produce a failstorm. That backoff has never worked. delayMs
starts at InitialDelayMs, which was 0, and the ramp multiplies:
delayMs = Math.Min(delayMs * 10, MaxDelayMs); // Math.Min(0, 10000)
Zero times ten is zero, so every round waited nothing and the client
reattempted as fast as the failures came back, for as long as the outage
lasted. The 0 and the ramp arrived in the same commit (NCBC-2518, 2020),
so this has never worked at any point.
Modification
============
InitialDelayMs 0 -> 100, which is the ramp the arithmetic was written
for: 100ms, 1s, 10s, 10s. Nothing else about the ramp changes.
The ticket asked instead for a 0 floor, keeping the first retry
immediate so that recovery from a blip stayed as fast as today. That
does not hold: delayMs is reset on every config line received, so a node
which accepts, delivers one config and drops would be reconnected with
no pause at all. Only a non-zero floor closes that.
The wait is now given the listener's cancellation token, which it had
none of - harmless at 0ms, but a real wait would leave the loop alive
for up to ten seconds after the bucket was closed.
The round now checks that token per node, not only per lap. A shutdown
landing mid-round previously walked every node still in the list,
building an HttpClient for each and logging its immediate cancellation
at Error as "HTTP Streaming error.".
Task.Run is no longer handed the token either. That overload only
declines to start the delegate, so a Dispose between scheduling and
dispatch ended the task Canceled for DisposeAsync to rethrow. It has
been passed since 2020, and the loop checks the token itself, so the
overload buys nothing.
The token is captured before the task is scheduled and used throughout
it, so the loop never touches the source. Dispose cancels the source and
then disposes it, and reading Token afterwards throws
ObjectDisposedException, which the inner catch logged as an ordinary
shutdown error. The capture itself can still throw if a Dispose beats
StartListening's _disposed check - the window master already had,
reading Token for the Task.Run overload.
Tests drive an internal Delay seam, as StellarRetryHandler does: a
zero-length wait never creates a timer, so an injected TimeProvider
observes nothing and a test built on one would hang rather than fail.
Four tests, none waiting on a clock, each fix mutation tested.
Behaviour change
================
Deliberate: reconnecting after a healthy stream ends now waits 100ms
rather than nothing. That is what java does, and on purpose -
ClusterManagerBucketRefresher turns a normally completed stream into an
error so it goes through the same backoff.
The shape of the ramp is left alone: x10 reaches the ceiling after two
failed rounds, which for a memcached bucket means up to 10s blind to a
topology change. These values have never executed in a shipped build, so
choosing better ones is a real decision - but a separate one from making
the backoff run at all. The PR carries the detail.
Results
=======
Unit suite green on net8.0 and net10.0 (3000 passed).
https://couchbasecloud.atlassian.net/browse/NCBC-4298
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0480ba1 to
d63f9ab
Compare
Motivation
HttpStreamingConfigListenertries every management node in turn, and when all of them have failed it waits before starting the round again — with a delay meant to grow to 10 s, explicitly so that a total failure does not produce a failstorm (the comment atHttpStreamingConfigListener.cs:158-160says exactly that).That backoff has never worked:
delayMsstarts at 0 and zero times ten is zero, so every round waited nothing: while all management endpoints were unreachable the client reattempted as fast as the failures came back, building anHttpClientand throwing and logging an exception per lap, for as long as the outage lasted. The0and the* 10ramp arrived in the same commit (NCBC-2518, 2020), so this has never worked at any point.Modification
Four changes.
1.
InitialDelayMs0 → 100, which is the ramp the arithmetic was clearly written for: 100 ms, 1 s, 10 s, 10 s. Nothing else about the ramp changes.This is not what the ticket asked for, and the divergence is deliberate. NCBC-4298 asked for a
0floor —0 → 100 ms → 1 s → 10 s— on the grounds that keeping the first retry immediate leaves recovery from a transient blip exactly as fast as it is today, and that raisingInitialDelayMsis "a behaviour change nobody asked for". That reasoning doesn't survive contact with the reset:delayMsis reset toInitialDelayMson every config line received, not just at the start. So under a0floor, a node that accepts the connection, delivers one config and drops would be reconnected with no pause at all — the hot loop simply survives for the flapping case instead of the dead-endpoint case. Only a non-zero floor closes it. The ticket has been updated to match.2. The wait is now given the listener's cancellation token. It had none — harmless at 0 ms, but a real wait without one would leave the background loop alive for up to ten seconds after the bucket was closed.
3. The round now checks that token per node, not only per lap. A shutdown landing mid-round previously walked every node still in the list, building an
HttpClientfor each and getting an immediateTaskCanceledExceptionfromGetAsync, which the inner catch logs at Error as"HTTP Streaming error."— one entry per management node on an ordinary bucket close. That's the same failstorm the backoff exists to prevent, on the shutdown path.4.
Task.Runis no longer handed the token. Copilot's second-pass finding, and correct: that overload only declines to start the delegate, so aDisposelanding between scheduling and dispatch ends the taskCanceledforDisposeAsyncto rethrow asTaskCanceledExceptionon an ordinary shutdown. Worth noting it is pre-existing, not introduced here —masterpassed_cancellationTokenSource.Tokento the sameTask.Runsince NCBC-2518 in 2020; the diff only changed which expression supplies it. It is also unreachable today, since nothing in production awaits the task and every test waits for the loop to have started before disposing. Taken anyway: the loop's first act is to check the token, so the overload buys nothing, and this is the last wayDisposeAsyncthrows on a clean shutdown.Not tested, for the same reason as the
ObjectDisposedExceptionbelow: it needs aDisposeto interleave between scheduling and dispatch, which is not deterministically reproducible.The token is captured once before the loop and used throughout it: both
IsCancellationRequestedchecks, the streaming request, and the wait, so the loop never touches the source.Disposecancels the source and then disposes it, and readingTokenafterwards throwsObjectDisposedException— which the inner catch logged as"HTTP Streaming error."on an ordinary shutdown. The capture itself can still throw, if aDisposelands betweenStartListening's_disposedcheck and the capture; that is the windowmasteralready had, which readTokenfor theTask.Runoverload at the same point on the same thread, so it is neither new nor widened here. It surfaces to the caller, which is whatStartListeningalready documents for a disposed listener.Tests
Four, none of which waits on a clock: the backoff grows and caps; disposal during a backoff does not wait it out; disposal mid-round does not try the remaining nodes; and the existing "keeps going after failures" test, which reached its third attempt instantly only because of this defect and now skips the wait rather than serving it.
They drive an internal
Delayseam (asRetryOrchestratorandStellarRetryHandleralready do) rather than aTimeProvider, and that is forced by the defect itself: a zero-length wait completes without ever creating a timer, so a fake clock observes nothing at all — a test built on one would hang rather than fail. Recording the requested duration is the only way to see a wait that never happened.Three of the four were mutation tested, so no test passes vacuously (the fourth is not deterministically reproducible):
InitialDelayMsback to0Expected [100ms, 1s, 10s, 10s],Actual [0, 0, 0, 0]Expected: 1,Actual: 3The
ObjectDisposedExceptionbehaviour is not directly tested: provoking it needs disposal to interleave with setting up a request, which costs more test machinery than a one-line fix is worth.Behaviour change
Deliberate, and it reaches beyond the outage case, so worth reviewing explicitly:
delayMsis also reset toInitialDelayMsafter each config line received, so reconnecting after a healthy stream ends now waits 100 ms rather than nothing.That is what java does, and on purpose —
ClusterManagerBucketRefresherconverts a normally completed stream into an error so that it goes through the same retry path:What this PR deliberately does not do
Pick the shape of the ramp. ×10 reaches the 10 s ceiling after only two failed rounds — attempts land at t=0, 0.1 s, 1.1 s, 11.1 s, so three inside the first four seconds. Java doubles from 32 ms to ~4 s and gets roughly eight in the same window. For a memcached bucket that gap matters more than it looks, because this listener is the only ongoing config source (
ConfigHandler.Subscribestarts it eagerly only forMemcachedBucket; couchbase buckets get it only when KV polling fails in a mixed cluster), so after a brief management blip a topology change can go unnoticed for up to 10 s.The values are left alone because they are the ones already in the file — but since they have never executed in any shipped build, "already in the file" is not evidence they were ever right. Choosing them is a real decision, and a separate one from making the backoff run at all. Happy to fold a change in here if a reviewer would rather settle it now.
Results
Unit suite green on net8.0 and net10.0 (3000 passed).
https://couchbasecloud.atlassian.net/browse/NCBC-4298
🤖 Generated with Claude Code