Skip to content

Leave the consumer group explicitly on shutdown - #2819

Draft
delthas wants to merge 8 commits into
improvement/BB-835/rebalance-guardfrom
improvement/BB-833/leave-group-on-shutdown
Draft

Leave the consumer group explicitly on shutdown#2819
delthas wants to merge 8 commits into
improvement/BB-835/rebalance-guardfrom
improvement/BB-833/leave-group-on-shutdown

Conversation

@delthas

@delthas delthas commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Stacked on #2818 (BB-835). Review that one first — this branch contains its commit, and the base will move to development/9.5 once it merges.

close() unsubscribed and then waited for the rebalance callback to un-assign before disconnecting. librdkafka delivers no such callback when the consumer holds no assignment, and postpones the unsubscribe outright while a rebalance is in progress — so close() never returned and the pod was SIGKILLed with the member still registered at the broker.

sequenceDiagram
    participant C as BackbeatConsumer
    participant K as Kafka
    Note over C: SIGTERM during a rebalance
    C->>K: unsubscribe()
    Note right of K: postponed — a rebalance<br/>is already in progress
    C->>C: wait for 'unassign' … forever
    Note over C: SIGKILL at the grace period,<br/>no LeaveGroup ever sent
    Note over K: member still registered,<br/>may be elected leader of the next<br/>generation and never SyncGroup
Loading

The group then holds zero partitions until session.timeout.ms (45 s) evicts the member. During a rolling update a rebalance is in progress essentially by construction, since the new pod joins before the old one is told to stop.

Changes

Release the partitions and drop the subscription before closing, so the close path has nothing to hand back to us and the LeaveGroup goes out whatever state the group is in:

before   unsubscribe → wait for a revoke callback → [ drain → commit → unassign ] → disconnect → wait 'disconnected'
after    drain → commit → unsubscribe → unassign → disconnect → wait 'disconnected'

The bracketed steps only ran if librdkafka delivered a revoke callback. It delivers none when the consumer holds no assignment, and postpones the unsubscribe outright while a rebalance is in progress — in both cases the wait never ends. The same steps now run unconditionally, in close() itself.

The order of the last two matters, and the mechanism is a flag rather than the assignment list. Only rd_kafka_cgrp_unsubscribe() sets F_LEAVE_ON_UNASSIGN_DONE, and only unassign_done() — reached from WAIT_UNASSIGN_CALL — consults it to send the LeaveGroup. Un-assigning first therefore clears the assignment without any state change and the LeaveGroup is never armed; unsubscribe() then fires a revoke at us and parks in WAIT_UNASSIGN_CALL, leaving the LeaveGroup gated on a callback round trip that disconnect() is simultaneously blocking on. Unsubscribing first puts us in the one join-state where unassign() is meaningful, so our own call completes it and sends the LeaveGroup before disconnect() is reached.

Draining also means we must stop fetching. Nothing did: every completed task re-armed _tryConsume(), so the pipeline refilled as fast as it emptied and the departure waited on work that arrived after the shutdown began. Against a 3000 message backlog:

concurrency task close() tasks started after close
10 200 ms 6210 ms → 175 ms 301 → 0
10 50 ms 9482 ms → 31 ms 1864 → 0

The same guard ends the self-rescheduling consume loop, which otherwise kept polling a closed client for the lifetime of the process.

  • A revoke arriving once the shutdown has started is answered on the spot. librdkafka requires every rebalance callback to be answered, and leaving it for close() to answer by un-assigning later is not enough: one raised after close() has already un-assigned has nothing left to answer it, so the client stays in the rebalance and the disconnect wedges on it. Answering does not cut the drain short — close() still waits for the in-flight work, the partitions are simply handed back sooner, and the offsets that drain exists to commit (BB-758) are unaffected.
  • A deferred un-assign from before the shutdown is treated as superseded, and a partition grant arriving mid-close no longer tears down the drain close() installed. Both previously left close() waiting on a callback that could never fire.
  • close() no longer waits on an in-flight backlog publish, which could otherwise keep it rescheduling itself every second indefinitely.
  • A partition grant arriving after close() has released the partitions is declined rather than accepted, so the disconnect is not handed an assignment it must revoke all over again.
  • The final disconnect is bounded, as a backstop only. Measured, that bound firing predicted the process failing to exit in 40 of 40 CI runs — it returned from close() looking successful while leaving a client whose destructor blocks. Answering the rebalance callback above is what stops it firing; the bound is no longer load-bearing.

In-flight work is still drained before the partitions are released, so offsets are committed exactly as before, and that wait keeps the bound it already had through the revoke path (max.poll.interval.ms - 1000) — a wedged task delays the departure no longer than it does today. Draining is skipped once the client is disconnected, since there is then nothing to commit and no partitions to give back.

That bound is inherited, not chosen: at the default max.poll.interval.ms it is ~299 s, far longer than a pod's grace period, so a wedged task is still killed rather than departing cleanly. Replacing it with a deadline derived from the grace period is the budget work, deliberately left out here — BB-854 shortens the drain first.

Verification

Unit tests for the call ordering, completion with no assignment held, the drain wait, the offset-publish skip, watchdog cleanup, and a revoke arriving mid-drain. Each was checked against the previous implementation to confirm it fails there.

Two functional tests against a real broker. The second reproduces the incident: a newcomer joins, and the member being closed has already released its partitions and is waiting to rejoin, so the rebalance is still in progress and nothing will revoke back to it.

before after
close with the group settled 3994 ms 3994 ms
close during a rebalance close() never returns 10008 ms

The first passes either way — it guards the unsubscribeunassign ordering, since reverting that gates the LeaveGroup on a callback disconnect() is blocking on and the takeover falls off the 45 s cliff. The second is the regression guard.

Whether the process then exits was measured separately, 8 full runs of the lib suite per tree: 9.5 exits 8/8, this branch 8/8. Before the callback fix it was 1/8, and every wedged run had the disconnect bound firing first.

Beyond that, a pod-level census on real CI runners: a pod is terminated in each of four states, and what both pods actually processed is reconciled against what was produced. 288 iterations per round, two arms measured by identical harness code with only lib/ differing.

this branch before
exits without being SIGKILLed 100% 0%
median takeover 3.3 s 16.3 s
takeover over 10 s 9% 100%
takeover over 30 s 4% 12%
messages lost 0 0

n = 62 / 77 valid samples. Across the two rounds (290 valid samples) no iteration lost a message, and none ever committed an offset past work it had not finished — the property that matters more than the timing.

The census also found a defect that review had not: the rebalance callback still accepted a partition grant after close() had handed the partitions back, so the disconnect had to revoke them again, wedged, hit its 5 s bound, and returned without a LeaveGroup — the surviving members then waited out session.timeout.ms. That was 6 of 69 iterations; declining the grant took the SIGKILL rate to zero and halved the residual.

What remains is ~4% of departures still landing at 40-45 s, i.e. eviction rather than a departure. That signature is the orphaned member id tracked upstream as BB-843, not this path, but that attribution is a hypothesis rather than something these runs establish.

Issue: BB-833

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.59%. Comparing base (95f22dd) to head (471ecad).

Additional details and impacted files

Impacted file tree graph

Files with missing lines Coverage Δ
lib/BackbeatConsumer.js 94.38% <100.00%> (-1.44%) ⬇️
lib/constants.js 100.00% <ø> (ø)

... and 1 file with indirect coverage changes

Components Coverage Δ
Bucket Notification 80.27% <ø> (ø)
Core Library 81.44% <100.00%> (-0.02%) ⬇️
Ingestion 70.09% <ø> (ø)
Lifecycle 80.46% <ø> (ø)
Oplog Populator 85.83% <ø> (ø)
Replication 62.01% <ø> (ø)
Bucket Scanner 85.76% <ø> (ø)
@@                          Coverage Diff                           @@
##           improvement/BB-835/rebalance-guard    #2819      +/-   ##
======================================================================
+ Coverage                               75.57%   75.59%   +0.01%     
======================================================================
  Files                                     200      200              
  Lines                                   13941    13991      +50     
======================================================================
+ Hits                                    10536    10576      +40     
- Misses                                   3395     3405      +10     
  Partials                                   10       10              
Flag Coverage Δ
api:retry 9.06% <0.00%> (-0.04%) ⬇️
api:routes 8.82% <0.00%> (-0.04%) ⬇️
bucket-scanner 85.76% <ø> (ø)
ft_test:queuepopulator 9.27% <7.14%> (+0.09%) ⬆️
ingestion 12.21% <1.42%> (-0.04%) ⬇️
lib 9.16% <91.42%> (+0.30%) ⬆️
lifecycle 19.45% <58.57%> (+0.11%) ⬆️
notification 1.01% <0.00%> (-0.01%) ⬇️
oplogPopulator 0.13% <0.00%> (-0.01%) ⬇️
replication 19.00% <58.57%> (+0.04%) ⬆️
unit 55.54% <100.00%> (+0.30%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread lib/BackbeatConsumer.js
@delthas
delthas force-pushed the improvement/BB-833/leave-group-on-shutdown branch 8 times, most recently from f6faeab to 51e6b5d Compare August 26, 2026 15:20
@delthas
delthas force-pushed the improvement/BB-833/leave-group-on-shutdown branch from 7c660cd to c2b1156 Compare August 27, 2026 10:05
close() unsubscribed and then waited for the rebalance callback to
un-assign before disconnecting. librdkafka delivers no such callback
when the consumer holds no assignment, and postpones the unsubscribe
outright while a rebalance is in progress, so close() never returned:
the pod was SIGKILLed at the end of its grace period with the member
still registered at the broker. The coordinator then kept the group
waiting for a process that no longer existed, and could elect it leader
of the next generation, leaving every member without partitions until
the session timeout evicted it.

Leave the group from close() itself, in the order the group state
machine needs: commit, unsubscribe, un-assign, disconnect. Un-assigning
does not clear the group assignment, so unsubscribing first is what
parks the protocol on an un-assign, and our own un-assign then completes
it and sends the LeaveGroup, before disconnect() and without depending
on a revoke callback reaching us mid-close.

In-flight work is still drained first so its offsets are committed, with
the bound it already had through the revoke path, and skipped once the
client is disconnected, when there is nothing left to commit and no
partitions to give back. A revoke arriving during the shutdown is left
for close() to answer rather than releasing the partitions early, which
would cut that drain short and strand the offsets it exists to commit.
Deferred un-assigns from before the shutdown are superseded for the same
reason, and a partition grant arriving mid-close no longer tears down
the drain close() is waiting on.

Also stop waiting on an in-flight backlog publish, which could keep
close() rescheduling itself indefinitely, and bound the final disconnect
so a client that will not finish tearing down cannot hold the process
after the group has already been left.

Issue: BB-833
close() drains the in-flight work before releasing the partitions, but
nothing stopped the fetch loop while it waited: every completed task
re-armed _tryConsume(), so the pipeline refilled as fast as it drained
and the departure was delayed by work that arrived after the shutdown
had begun. Measured against a 3000 message backlog, close() took 6.2s
and started 301 further tasks at concurrency 10, and 9.5s and 1864
further tasks with shorter ones; with the guard both are 0 further
tasks, in 175ms and 31ms.

The same guard ends the self-rescheduling consume loop, which otherwise
kept polling a closed client for the lifetime of the process.

Issue: BB-833
The services install their SIGTERM handlers with process.on rather than
once, so a repeated signal calls close() again. The second call started
its own drain wait, overwriting the single drain slot the first was
waiting on, and the first caller was then only released by its own
timeout, minutes after the consumer had already left the group.

Coalesce instead: the first call runs the shutdown, later ones attach to
it, and every caller is answered once it completes.

Issue: BB-833
librdkafka delegates assignment to the rebalance callback and requires
it to call assign(NULL) when the event is neither an assign nor a
revoke, to resynchronise state. Ours only logged.

That was harmless while the callback kept no state of its own, but it
now supersedes any deferred un-assign on its way past and cancels the
watchdog that would have forced one: the consumer would keep partitions
it was told to release, with nothing left to notice. Un-assign instead,
unless a shutdown is already under way, where close() does it.

Issue: BB-833
close() hands the partitions back, but the rebalance callback still
accepted a fresh grant afterwards, leaving the client holding an
assignment the disconnect then had to revoke all over again. That wedged
the disconnect: it hit the 5s bound, close() returned without a
LeaveGroup having been sent, and the surviving members waited out
session.timeout.ms instead.

Measured across 288 CI iterations against a real broker, this accounted
for every remaining degraded departure on the fixed tree -- 6 of 69, all
in the rejoining and draining states, median takeover 29.9s against 3.0s
when it did not fire, three of them at 40-44s which is eviction rather
than a departure.

Issue: BB-833
librdkafka requires every rebalance callback to be answered, and leaving
this one for close() to answer by un-assigning later is not enough: a
revoke raised after close() has already un-assigned has nothing left to
answer it. The client then stays in the rebalance, and the disconnect
waits on it until its bound gives up -- which returns from close()
looking successful while leaving a client whose destructor blocks, so the
process can never exit.

Measured across 8 full lib-suite runs per arm on CI, the correlation
between that bound firing and the process failing to exit was 40 out of
40. Answering the callback here takes the suite from 1 of 8 runs exiting
to 8 of 8, matching 9.5 itself.

It does not cut the drain short, which is what the previous comment
feared: close() still waits for the in-flight work, the partitions are
just handed back sooner. Across 240 iterations the draining scenario
keeps the same ~5.95s close and the same volume processed, with no
message lost, nothing committed past unprocessed work, and fewer
duplicates than before.

Answering only once close() had released was also measured, and fixes
almost nothing (2 of 8): the revokes that matter arrive before that.

Issue: BB-833
@delthas
delthas force-pushed the improvement/BB-833/leave-group-on-shutdown branch from fe56c8d to 829927b Compare August 27, 2026 16:44
Declining a partition grant during shutdown with assign([]) only works
until disconnect() starts. The binding gates assign() on isConnected(),
which Disconnect() clears before it takes its lock, while unassign() is
gated on !IsClosing() && !IsConnected() and so stays permitted for the
whole close. Since the fetch loop stops as soon as the shutdown begins,
the only thing still delivering rebalance callbacks by then is the
closing client itself -- so the decline was refused exactly when it was
needed, and swallowed by the best-effort wrapper.

It does not hang today: librdkafka's terminate0 unsubscribes on its own
and rejoins to INIT, which recovers the state. But that is the library
covering for us, and leaving a rebalance callback unanswered is the same
defect class as the revoke path fixed one commit earlier.

librdkafka coerces an assign into a full unassign once termination has
started, so unassign() is a valid answer to a grant here.

Issue: BB-833
The replication queue processor's stop() closes its status producer
before the consumers, and a task's offset only becomes committable from
that producer's delivery callback -- so a report that never arrives
leaves a ledger entry outstanding while the processing queue is idle.

Measured against a permanently stuck ledger, the previous close() never
returned at all; this one returns in maxPollIntervalMs - 1000 plus the
disconnect bound. The existing drain test holds both signals, so this
covers the ledger-only shape the real service produces.

Issue: BB-833
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.

1 participant