Skip to content

fix(queue): retry first-time provisioning of a replicated stream - #219

Merged
levivannoort merged 1 commit into
mainfrom
fix/nats-provisioning-retry
Sep 9, 2026
Merged

fix(queue): retry first-time provisioning of a replicated stream#219
levivannoort merged 1 commit into
mainfrom
fix/nats-provisioning-retry

Conversation

@levivannoort

Copy link
Copy Markdown
Contributor

What

Broker\Nats::ensure() now retries first-time provisioning with full jitter over an exponentially growing window.

Why

Concurrent first provisioning of a replicated stream falls off a cliff. Measured against a 3-node JetStream cluster at replicas=3:

case before after
cold, 1 process OK, ~290 ms OK, ~290 ms
cold, 2 processes 0 / 2 2 / 2
cold, 8 processes 0–3 / 8 8 / 8
warm, 8 processes 8 / 8, 19–34 ms 8 / 8

A single cold provision is fast and reliable; two at once both blow the client's 5 s request timeout and neither completes. The server logs give the mechanism — the competing CREATEs drive the stream and consumer RAFT groups into repeated leader elections, which continue for minutes after every caller has given up:

[INF] JetStream cluster new stream leader for '$G > Q_…'
[INF] JetStream cluster new consumer leader for '$G > Q_… > worker'
... repeating

The retry works because the expensive state does not survive the attempt: once one process wins, the stream exists and every later caller takes the warm path, which is clean at eight concurrent processes even with the retry removed. So a retry is not a rerun of the same race.

This is a queue's very first deploy, not a scale-up. A KEDA 0→N expansion runs against an existing stream — the warm row. The failure was originally read as a scale-up hazard because the benchmark's --cleanup=1 default dropped the streams after every run, making every run a cold one.

Rejected alternative

Reading the current config first to skip a no-op write made it worse — the extra round trips spend the same 5 s budget, taking 8 concurrent cold starts from 8/8 down to 7/8. Measured, not assumed.

Coverage

None, and none that would mean anything here: reproducing this needs a replicated cluster, and this package's E2E suite runs single-node, where the retry can be deleted and everything stays green. appwrite/cloud carries the reproduction as benchmarks/nats-provisioning.php (--reset=1 first, or you measure the warm path).

Tests

phpunit --testsuite unit — 113 tests, 289 assertions. Pint clean.

Concurrent *first* provisioning of a replicated stream does not degrade
gracefully. Measured against a 3-node JetStream cluster at replicas=3: creating a
fresh stream and its two durable consumers takes ~290ms from a single process and
succeeds every time; two processes doing it at once both exceed the client's 5s
request timeout and neither completes. It is a cliff, not a slope -- eight
concurrent cold starts landed 0-3 successes.

The server logs give the mechanism: the competing CREATEs drive the stream and
consumer RAFT groups into repeated leader elections, which continue for minutes
after every caller has given up.

The retry works because the state that makes an attempt expensive does not survive
it. Once one process wins, the stream and consumers exist, and a repeat CREATE for
an identical config causes no further election -- eight concurrent processes
against an existing stream provision in 19-34ms with no election at all. Measured
after the fix: 8/8 at eight concurrent cold starts, 2/2 at two (both previously 0).

This is the first pod that ever touches a queue, not a scale-up: a KEDA 0->N
expansion runs against a stream that already exists and is the cheap case above.
That distinction matters -- the failure was originally read as a scale-up hazard
because the benchmark's --cleanup default made every run a cold one.

Reading the current config first, to skip a no-op write, was tried and made it
worse: the extra round trips spend the same 5s budget, taking eight concurrent
cold starts from 8/8 down to 7/8.

No automated coverage, and none that would mean anything: the failure needs a
replicated cluster, and this package's E2E suite runs single-node, where the retry
can be deleted and everything stays green. appwrite/cloud carries the
reproduction as benchmarks/nats-provisioning.php.
@levivannoort
levivannoort merged commit eb80bf3 into main Sep 9, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants