fix(router): close Kafka consumer client when subscription ends - #3133
fix(router): close Kafka consumer client when subscription ends#3133mwisner wants to merge 3 commits into
Conversation
ProviderAdapter.Subscribe creates a dedicated kgo.Client per subscription but never closes it. When a subscription ends the poller goroutine returns and the client is dropped without Close(); franz-go clients own background goroutines that keep the client reachable, so it is never garbage collected. Every ended subscription therefore permanently leaks a client together with its broker connections and buffered fetches, growing heap and goroutine count with the cumulative (not concurrent) subscription count until the process OOMs. A second issue makes it worse: topicPoller blocks in PollRecords on the adapter (application) context, not the subscription context, so a subscription cancelled while its topic is idle never unblocks the poller and neither the goroutine nor the client is ever reclaimed. Close the consumer client on every poller exit path via a sync.Once-guarded defer, and register a context.AfterFunc on the subscription context that closes the client on cancellation so an in-flight PollRecords returns IsClientClosed and the poller exits promptly. This also makes Shutdown reclaim consumer clients via the existing closeWg.Wait(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
WalkthroughKafka subscription pollers now use the subscription context for polling and metrics. The adapter derives a polling context that combines subscription and adapter cancellation. Pollers run with ChangesKafka subscription context handling
Estimated code review effort: 2 (Simple) | ~10 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 golangci-lint (2.12.2)level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies" Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
router/pkg/pubsub/kafka/adapter.go (1)
169-184: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse
closeWg.Gofor this goroutine.The router module targets Go 1.25.0, so replace
p.closeWg.Add(1), the goroutine, and deferredp.closeWg.Done()withp.closeWg.Goto keep lifecycle accounting in one API call.Proposed refactor
- p.closeWg.Add(1) - - go func() { - defer p.closeWg.Done() + p.closeWg.Go(func() { // existing poller cleanup and polling logic - }() + })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@router/pkg/pubsub/kafka/adapter.go` around lines 169 - 184, Update the goroutine lifecycle management in the surrounding poller code to use p.closeWg.Go instead of manually calling p.closeWg.Add(1), launching a goroutine, and deferring p.closeWg.Done(). Preserve the existing goroutine body and ensure the closeWg.Go callback contains the current cleanup logic.Source: Learnings
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@router/pkg/pubsub/kafka/adapter.go`:
- Around line 169-184: Update the goroutine lifecycle management in the
surrounding poller code to use p.closeWg.Go instead of manually calling
p.closeWg.Add(1), launching a goroutine, and deferring p.closeWg.Done().
Preserve the existing goroutine body and ensure the closeWg.Go callback contains
the current cleanup logic.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: da2bb928-0e66-4949-937a-cec54aca0353
📒 Files selected for processing (1)
router/pkg/pubsub/kafka/adapter.go
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3133 +/- ##
===========================================
+ Coverage 48.94% 54.58% +5.63%
===========================================
Files 1130 247 -883
Lines 157571 30493 -127078
Branches 10883 0 -10883
===========================================
- Hits 77130 16644 -60486
+ Misses 78588 12242 -66346
+ Partials 1853 1607 -246
🚀 New features to boost your workflow:
|
Address review feedback: replace the manual closeWg.Add(1) / go func() / defer closeWg.Done() with sync.WaitGroup.Go (Go 1.25). Behaviour is unchanged; the consumer-client cleanup logic is preserved verbatim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Hey @mwisner, thanks for the finding! I had a look at this and was able to reproduce the issue. Are you open to a few adjustments in this PR? Your fix works but ideally I would like to solve it differently: Make the topic poller return immidiately by using their contexts correctly and close the kafka client alongside it. Currently the topic poller mixes up using p.closeWg.Go(func() {
defer client.Close()
pollerCtx, cancel := context.WithCancel(ctx) // cancels pollerCtx when ctx is cancelled
defer cancel()
stop := context.AfterFunc(p.ctx, cancel) // cancels pollerCtx when p.ctx is cancelled, too
defer stop()
err := p.topicPoller(pollerCtx, ...)
// [...]
}Inside This will lead to the poller goroutine exiting immidiately when either a trigger closes or when the router shuts down or hot reloads. When that happens the If you want I can also close this PR, cherry-pick your commits here into a new PR with these changes. Just let me know. |
Apply review feedback: instead of closing the client from a cancellation hook, derive the poller context from the subscription context and also cancel it when the adapter (application) context is cancelled, then pass that single context to topicPoller. topicPoller no longer references p.ctx (PollRecords and metric emission both use the passed context), so it returns immediately on a trigger close, router shutdown or hot reload. A single deferred client.Close() then reclaims the client, removing the need for the sync.Once guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for reproducing it and for the guidance — that approach is cleaner, applied in the latest push:
Result: the poller returns immediately on a trigger close, router shutdown, or hot reload, and the client is closed alongside it. Happy to keep it in this PR (no need to re-open a new one), and I can add a regression test asserting the poller exits and the client is closed on subscription-context cancellation if you'd like it here. |
Summary by CodeRabbit
Description
kafka.ProviderAdapter.Subscribecreates a dedicatedkgo.Clientper subscription and never closes it. When a subscription ends, the poller goroutine returns and the client is simply dropped —Close()is never called. Because a franz-go client owns background goroutines that keep it reachable, the client is never garbage collected, so every ended subscription permanently leaks akgo.Clienttogether with its broker connections and buffered fetches. Heap usage and goroutine count grow with the cumulative (not concurrent) number of subscriptions until the process is OOM-killed. The only consumer teardown in the adapter today is for the shared producer (writeClient), closed inShutdown.A second, compounding issue:
topicPollerblocks inPollRecordson the adapter (application) context, not the per-subscription context. A subscription cancelled while its topic is idle therefore never unblocks the poller, so neither the goroutine nor its client is reclaimed even at the loop level.How to reproduce
inuse_spaceheap andgoroutinecount climb monotonically with the total number of subscriptions ever created and never fall — independent of how many subscribers are currently active.PollRecords).Fix
In the
Subscribepoller goroutine:sync.Once-guardeddefer(Client.Closeis not safe to call twice).context.AfterFunc(ctx, closeClient)on the subscription context, so that when the subscription is cancelled an in-flightPollRecordsreturnsIsClientClosedand the poller exits promptly — even on an otherwise idle topic.As a side benefit,
Shutdownnow also reclaims consumer clients through the existingcloseWg.Wait(). The change is minimal (no new imports, no public API change) and preserves the stateless-consumer model.Checklist
Open Source AI Manifesto
This project follows the principles of the Open Source AI Manifesto. Please ensure your contribution aligns with its principles.
Notes for reviewers
Opening as a draft for discussion. This is a bugfix rather than a feature, so no prior issue was opened — happy to file one if you'd prefer. I'd also be glad to add a regression test asserting the consumer client is closed on subscription-context cancellation (e.g. that
PollRecordsreturnsIsClientClosedand the poller goroutine exits) before this is marked ready.Prepared with assistance from Claude (per the Open Source AI Manifesto's transparency principle).