manager/logbroker: bound the log and subscription queues - #3267
Open
Leekyungun wants to merge 1 commit into
Open
manager/logbroker: bound the log and subscription queues#3267Leekyungun wants to merge 1 commit into
Leekyungun wants to merge 1 commit into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #3267 +/- ##
==========================================
+ Coverage 14.73% 14.76% +0.03%
==========================================
Files 200 200
Lines 93077 93101 +24
==========================================
+ Hits 13712 13746 +34
+ Misses 78019 78000 -19
- Partials 1346 1355 +9 🚀 New features to boost your workflow:
|
LogBroker.Start creates both of its queues with watch.NewQueue() and no options, which yields a LimitQueue with limit 0 -- documented as "limitless". Each watcher's sink is an unbuffered channel, so once a watcher stops reading, LimitQueue.Write keeps appending to an unbounded container/list instead of applying backpressure or shedding load. ListenSubscriptions stops draining its channel whenever stream.Send blocks, which happens when an agent's gRPC stream stalls. Because both registerSubscription and unregisterSubscription publish the *subscription to subscriptionQueue, a single stalled watcher pins every subscription that has passed through the broker -- along with its SubscriptionMessage, LogSelector, LogSubscriptionOptions and cancel context -- even though each subscription was unregistered correctly. The backlog accumulates in a container/list rather than in blocked goroutines, so the growth is invisible to goroutine-count based monitoring and surfaces only as unexplained manager heap growth. Bound both queues and close the watcher's output channel on teardown, so a consumer that falls too far behind is disconnected instead of being buffered without bound. Disconnecting is recoverable: ListenSubscriptions returns an error, the agent reconnects, and watchSubscriptions replays the currently registered subscriptions for that node. Both receive sites now handle a closed channel. Without that, closing the output channel would turn the leak into a nil type-assertion panic. The added test drives subscriptions through their full lifecycle (Run -> register -> unregister -> Stop) and uses finalizers to check whether the runtime can reclaim them. Against an unbounded queue a stalled watcher retains all 3000 of them; bounded, retention is capped by subscriptionQueueLimit. Relates to moby/moby#46068 Signed-off-by: leekyungun <leekyungun91@gmail.com>
Leekyungun
force-pushed
the
logbroker-bound-queues
branch
from
July 29, 2026 01:11
e8b0e19 to
4f043cf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
- What I did
Bounded the log broker's two queues so that a consumer which stops draining can
no longer cause unbounded manager memory growth.
LogBroker.Startcreates both queues withwatch.NewQueue()and no options,which yields a
LimitQueuewithlimit: 0-- documented as "limitless".LimitQueue.Writeskips its size check entirely in that case:Each watcher's sink is an unbuffered channel (
events.NewChannel(0)inwatch/sinks.go), so once a watcher stops reading, the queue keeps appending toan unbounded
container/listrather than applying backpressure or sheddingload.
ListenSubscriptionsstops draining its channel wheneverstream.Sendblocks,which happens when an agent's gRPC stream stalls -- an unresponsive or
partitioned node, for example. Both
registerSubscriptionandunregisterSubscriptionpublish the*subscriptiontosubscriptionQueue, soa single stalled watcher pins every subscription that has passed through the
broker, along with its
SubscriptionMessage,LogSelector,LogSubscriptionOptionsand cancel context -- even though each subscription wasunregistered correctly.
Because the backlog accumulates in a
container/listrather than in blockedgoroutines, the growth is invisible to goroutine-count based monitoring and
surfaces only as unexplained manager heap growth.
This matches the profile in moby/moby#46068. We hit it in production: a
single-node swarm manager reached ~27 GB RSS with a normal goroutine count (320)
and normal fd count (143). A heap profile attributed 99.4% of the heap to the log
broker:
- How I did it
Gave both queues a limit and
WithCloseOutChan(), so a consumer that falls toofar behind is disconnected instead of buffered without bound. Disconnecting is
recoverable:
ListenSubscriptionsreturns an error, the agent reconnects, andwatchSubscriptionsreplays the currently registered subscriptions for thatnode.
Both receive sites now handle a closed channel. Without that, closing the output
channel would turn the leak into a nil type-assertion panic on
v.(*subscription).- How to test it
The added test drives subscriptions through their full lifecycle
(
Run->register->unregister->Stop) and uses finalizers to checkwhether the runtime can reclaim them. A subscription that has been unregistered
is no longer referenced by any of the broker's bookkeeping maps, so a correctly
behaving broker must allow it to be collected regardless of what the watcher is
doing.
Retention after the change is bounded rather than zero, because the queue may
legitimately hold up to
subscriptionQueueLimitevents. Each subscriptionpublishes twice (register + unregister), so ~501 subscriptions are retained.
Scaling the input confirms the bound holds:
The test fails on
masterand passes with this change. Existingmanager/logbroker,watchandwatch/queuetests pass;go vetandgofmtare clean.
- Description for the changelog
Bound the swarm log broker's log and subscription queues so a stalled consumer
can no longer cause unbounded manager memory growth.
Open questions
I would appreciate guidance on two points.
1. The limit values are arbitrary. I chose
subscriptionQueueLimit = 1000and
logQueueLimit = 10000based on relative event frequency: subscriptionevents occur a couple of times per
docker service logsinvocation, while logevents track container output volume, so a single shared value would trip the
log queue under normal load.
That reasoning only accounts for frequency, not for per-event size. A
PublishLogsMessagecarries a batch of log lines and can be far larger than a*subscription, so 10,000 log events may well cost more memory than 1,000subscriptions. What we actually want to bound is bytes, but
watch.Queueonlyoffers a count-based limit. Happy to change these numbers, make them
configurable, or take a different approach.
2. Tearing the watcher down may be too blunt for
logQueue. ForsubscriptionQueuethe consumer is an agent, and disconnecting it triggers areconnect that re-syncs state. For
logQueuethe consumer is adocker service logsclient, where falling behind on a slow link is a fairlynormal condition, and the user sees the stream terminate with
ResourceExhausted.watch.WithTimeoutmay be a better fit there; I kept bothqueues consistent for now.
Notes
watch.WithLimitandwatch.WithTimeoutexist but are currently unused by anyproduction call site; all eight
watch.NewQueue()callers pass no options.Other queues may warrant the same treatment, but I have limited this change to
the broker, where there is a reproduction.
is the state a stalled
stream.SendleavesListenSubscriptionsin, but itdoes not reproduce the original production trigger (a node going offline and
returning, as also described in Memory leak of docked process moby#46068).
after
runtime.GC(); it was stable across 10 consecutive runs, but it would beworth confirming the behaviour on CI.