Backport upstream fix for the JetStream fetch lock-order inversion - #6
Conversation
Cherry-picked from nats-io/nats.c `7220e9f6e081` (nats-io#834), which fixes nats-io#823. Released upstream in v3.10.0; this fork's branch is cut from v3.9.2 and never inherited it. `src/` is upstream's verbatim, and upstream's `test/list_test.txt` hunk 2 is dropped because it reorders `_test(SSLVerificationCallback)`, a test this fork does not have. Upstream's new test is extended here with delivery counts and a custom inbox prefix arm, so that reverting either half of the fix reddens it. The library's lock order is `nc->mu`, then `nc->subsMu`, then `sub->mu` (taken with the subscription's dispatcher lock). `js_maybeFetchMore` inverted it by publishing the JetStream pull request while still holding the subscription and dispatcher locks, so `sub->mu` was held while `natsConn_publish` acquired `nc->mu`. That closes a cycle with the two ordinary edges: `natsConn_subscribeImpl` takes `nc->mu` then the subscription lock, and `natsConn_processMsg` takes `nc->subsMu` then the subscription lock. `js_PullSubscribeAsync` produced the same inverted edge a second way, because it called `js_maybeFetchMore` while already holding both locks and `natsMutex` is `PTHREAD_MUTEX_RECURSIVE`, so the violation of that function's own stated contract was silent rather than a self-deadlock. The fix publishes outside the locks and releases them before the first fetch, so both call paths honour the contract. ClickHouse reaches the cycle whenever a NATS JetStream table subscribes a second consumer, or a second subject, on one shared connection while the first subscription's dispatcher is already fetching. ThreadSanitizer reports it as `lock-order-inversion (potential deadlock)`, and it also materializes as a real deadlock, after which unrelated DDL on the server times out with `Timeout exceeded while reading from socket` for the life of the process. Validated with the extended `test_JetStream_GH823` under ThreadSanitizer with spinning disabled, three arms x five runs: unpatched reports the inversion on 5 of 5 runs, the partial fix without the `js_PullSubscribeAsync` change still reports it on 5 of 5, and the full fix reports none on 5 of 5. On the unpatched tree the extended test also fails its second-batch assertion on 5 of 5 runs, and keeps failing it when the wait is widened to 30 seconds, so the refetch there does not merely run late. With spinning left on, the unpatched tree deadlocks on 4 of 5 runs and the fixed tree on 0 of 5. The thirteen other JetStream tests, covering the rest of the pull and pull-async family, pass unchanged on both trees, and the two backported tests pass 70 of 70 runs on the fixed tree across the three build configurations. The backport also drops the fixed-size `jsFetch::replySubject` field in favour of a buffer sized from the subscription's subject. That field was `NATS_DEFAULT_INBOX_PRE_LEN + NUID_BUFFER_LEN + 32` bytes, which assumes the default inbox prefix, but `natsOptions_SetCustomInboxPrefix` accepts any valid prefix with no length bound, so a long enough custom prefix made `snprintf` truncate the reply subject silently. `test_JetStream_GH823CustomInboxPrefix` pins that: it fails on a tree that differs from this one only by restoring the field, while `test_JetStream_GH823` passes there. Only the truncation is fixed: `_fetchIDFromSubject` still parses the fetch id at a fixed default-prefix offset, so fetch status messages under a custom prefix stay unrecognized. That is pre-existing, unchanged here, and left to upstream. Two concurrency windows come with the patch, and both are unchanged in upstream `main` today, so this adopts upstream's current state rather than diverging from it. First, `js_maybeFetchMore` now publishes the request before it adds the batch to `fetch->requestedMsgs`, so a reply that lands in between lets the default `_autoNextFetchRequest` size the next request from a stale counter and exceed `jsFetchOptions.MaxMessages`; the surplus is then destroyed undelivered, and under `js_AckNone`, which a pull consumer on a limits-retention stream accepts, the server does not redeliver it. Second, the first fetch now runs unlocked, so a `NextHandler` or a first-message callback that destroys the not-yet-returned subscription races the dispatcher's own release. Neither is reachable from ClickHouse: its JetStream consumer leaves `MaxMessages` and `MaxBytes` at zero, sets `ManualAck`, and destroys no subscription from a callback. Reported on: ClickHouse/ClickHouse#96487 (comment) CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=96487&sha=3d1580985ee545bb52169f392baa8a3bc643d4fe&name_0=PR&name_1=Integration%20tests%20%28amd_tsan%2C%205%2F6%29 Related: ClickHouse/ClickHouse#96487 Related: nats-io#823 Related: nats-io#834
Internal second-model review: adjudication log (click to expand)Pre-publication review by an independent model over four rounds (engine: codex; 3 findings, then Rows 2 and 6 were raised in three separate rounds. My first refutation of each was wrong and the
Severity: ❌ blocker / Neither window in rows 2 and 6 is reachable from ClickHouse, which is why this backport is still Session id: cron:clickhouse-review-slot-9:20260902-035900 |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-impl-slot-5:20260902-010900 |
A shorter cycle through the same inverted edge, now firing on ClickHouse masterThe reachability argument in the description is a table subscribing a second consumer or subject while the first is fetching. Since 2026-09-06 the same edge closes a two-lock cycle on a single-consumer, single-subject table, and it fires on master rather than on a PR arm.
So one table that unsubscribes while the broker is delivering is enough. Both threads block on a plain What it costs, from That is 11 of 11 failures on the shard, NATS taking 44.1 % of its test seconds against a 12 to 16 % baseline on 14 other commits of the same day, and 6 tests never run.
|
What breaks
A pull-async JetStream subscription can deadlock the client threads: once it fires, nothing on
that connection completes again. ClickHouse hits it when a NATS JetStream table subscribes a
second consumer, or subject, on one connection while the first is fetching.
Root cause
The library's lock order is
nc->mu,nc->subsMu, thensub->mu(taken with thesubscription's dispatcher lock).
js_maybeFetchMoreinverted it by publishing the pull requestwhile still holding the subscription and dispatcher locks, so
sub->muwas held whilenatsConn_publishtooknc->mu. That closes a cycle withnatsConn_subscribeImplandnatsConn_processMsg, which take an outer lock first.js_PullSubscribeAsyncproduced the same edge a second way, callingjs_maybeFetchMorewhile already holding both locks;natsMutexis recursive, so breaking thatfunction's contract (
js.c:2907) was silent.The change
A cherry-pick of upstream
7220e9f6e081(nats-io#834, fixes nats-io#823),released in v3.10.0. This branch is cut from v3.9.2, so it was never inherited.
The
src/hunks are byte-identical to upstream; the one dropped hunk reorders_test(SSLVerificationCallback), absent from this fork.The new test is extended so reverting either half of the fix reddens it.
The publish moves outside the locks and
js_PullSubscribeAsyncreleases them before the firstfetch, so the inverted edge is removed, not guarded. The
jsFetch::replySubjectfieldgoes with it: its fixed 61 bytes assumed the default inbox prefix, which
natsOptions_SetCustomInboxPrefixdoes not bound, so a long prefix truncated the replysubject.
Left unfixed, all pre-existing. Custom inbox prefixes stay partly broken:
_fetchIDFromSubject(js.c:1705) parses the fetch id at a fixed default-prefix offset, sofetch status messages go unrecognized; the synchronous
_fetchpathkeeps the same 61-byte buffer.
applyNewSIDdrops onlysub->mubefore takingnc->subsMu, a different pair needing a shared dispatcher and ordered consumers.Two windows come with the patch, both still open in upstream
main, so this adopts upstream'sstate: the lock is dropped across the publish, so the
fetch->requestedMsgsupdate is no longer atomic with the fetch id bump and the next request canbe sized from a stale counter; and the initial fetch runs unlocked, so a callback destroying the
subscription before the call returns could free it mid-publish. Neither is reachable from
ClickHouse, which leaves
MaxMessagesandMaxBytesat 0 and setsManualAck.Validation (three sanitizer arms, a deadlock oracle, a sizing mutant)
test_JetStream_GH823, extended here to count deliveries per subscription and to wait for asecond batch on each, under ThreadSanitizer with
NATS_BUILD_NO_SPIN=ON(an uncontendednatsMutex_Lockspins ontrylock, which the detector cannot see). Five runs per arm.js_PullSubscribeAsynchunkThe middle arm is the sensitivity control: it shows the second hunk is load-bearing rather than
cosmetic. The assertion failure is a genuine stall, not a tight wait, since it still fails with
the wait widened to 30 seconds. With spinning left on, the same sanitizer build wedges the
unpatched tree on 4 of 5 runs and this one on 0 of 5.
Without a sanitizer the inversion is only a potential deadlock, so no functional assertion
reddens reliably there. That is what
test_JetStream_GH823CustomInboxPrefixcovers: it fails5 of 5 on a tree differing from this one only by restoring the fixed-size field, while
test_JetStream_GH823still passes there.The thirteen other JetStream tests pass unchanged on both trees, the two new tests pass 70 of 70
runs on this tree across three build configurations, and the patched library compiles clean in
ClickHouse.
The ClickHouse-side
contrib/nats-iobump follows in a separate pull request.Reported on: ClickHouse/ClickHouse#96487 (comment)
CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=96487&sha=3d1580985ee545bb52169f392baa8a3bc643d4fe&name_0=PR&name_1=Integration%20tests%20%28amd_tsan%2C%205%2F6%29
Related: nats-io#834