Skip to content

Commit 2d1a821

Browse files
sarg3ntclaude
andcommitted
fix(events): coalesce drop warnings; raise subscriber buffer 50→500
A slow SSE consumer falling behind on a bursty event stream (logs.updated emits one event per tailed log line) overflows the 50-slot subscriber channel and produces one WARN per dropped event, flooding gearbox-agent's journal with hundreds of identical lines. - Raise per-subscriber Events channel from 50 to 500 to absorb typical log bursts without dropping. - When drops do happen, aggregate per (subscriber, event_type) and log at most once per 10s with a cumulative count + window duration, so a burst is one line instead of N. - Clean up the drop tracker when a subscriber unsubscribes so it doesn't grow unbounded with churning SSE clients. Related: #60 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 667268d commit 2d1a821

2 files changed

Lines changed: 262 additions & 11 deletions

File tree

gearbox/internal/framework/events/hub.go

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,35 @@ type Subscriber struct {
9292
done chan struct{}
9393
}
9494

95+
// subscriberEventBufferSize is the per-subscriber Events channel capacity.
96+
// Sized to absorb bursts of high-frequency events (e.g. logs.updated emits
97+
// one event per log line tailed on the agent) without dropping. A slow SSE
98+
// consumer that stays behind this for the full window will still drop, but
99+
// the warning is throttled — see dropLogInterval.
100+
const subscriberEventBufferSize = 500
101+
102+
// dropLogInterval is the minimum gap between consecutive "channel full"
103+
// warnings for the same (subscriber, event_type) pair. Drops still happen
104+
// at the same rate; only the log output is coalesced so a bursty consumer
105+
// produces one line per interval instead of one per dropped event.
106+
const dropLogInterval = 10 * time.Second
107+
108+
// dropKey identifies a stream of drops to coalesce. Per-(subscriber, event-type)
109+
// so an overflow on logs.updated doesn't suppress a separate overflow on
110+
// metrics.updated for the same subscriber.
111+
type dropKey struct {
112+
subID string
113+
eventType EventType
114+
}
115+
116+
// dropAggregator tracks drops between log emissions for one dropKey.
117+
// Only accessed from the Hub's run goroutine, so no synchronization.
118+
type dropAggregator struct {
119+
count int64
120+
firstSeen time.Time
121+
lastLogged time.Time
122+
}
123+
95124
// Hub manages event distribution to subscribers.
96125
type Hub struct {
97126
subscribers map[string]*Subscriber
@@ -101,6 +130,9 @@ type Hub struct {
101130
register chan *Subscriber
102131
unregister chan *Subscriber
103132
done chan struct{}
133+
// subscriberDrops is owned by run() and must not be touched from other
134+
// goroutines.
135+
subscriberDrops map[dropKey]*dropAggregator
104136
}
105137

106138
// NewHub creates a new event hub.
@@ -110,12 +142,13 @@ func NewHub(logger *slog.Logger) *Hub {
110142
}
111143

112144
h := &Hub{
113-
subscribers: make(map[string]*Subscriber),
114-
logger: logger,
115-
broadcast: make(chan Event, 100),
116-
register: make(chan *Subscriber),
117-
unregister: make(chan *Subscriber),
118-
done: make(chan struct{}),
145+
subscribers: make(map[string]*Subscriber),
146+
logger: logger,
147+
broadcast: make(chan Event, 100),
148+
register: make(chan *Subscriber),
149+
unregister: make(chan *Subscriber),
150+
done: make(chan struct{}),
151+
subscriberDrops: make(map[dropKey]*dropAggregator),
119152
}
120153

121154
return h
@@ -159,6 +192,7 @@ func (h *Hub) run() {
159192
h.logger.Debug("subscriber unregistered", "id", sub.ID)
160193
}
161194
h.mu.Unlock()
195+
h.cleanupDropTracker(sub.ID)
162196

163197
case event := <-h.broadcast:
164198
h.mu.RLock()
@@ -172,10 +206,7 @@ func (h *Hub) run() {
172206
select {
173207
case sub.Events <- event:
174208
default:
175-
// Channel full, skip this event for this subscriber
176-
h.logger.Warn("subscriber event channel full, dropping event",
177-
"subscriber_id", sub.ID,
178-
"event_type", event.Type)
209+
h.recordDrop(sub.ID, event.Type)
179210
}
180211
}
181212
h.mu.RUnlock()
@@ -188,14 +219,61 @@ func (h *Hub) Subscribe(id string, serverID string) *Subscriber {
188219
sub := &Subscriber{
189220
ID: id,
190221
ServerID: serverID,
191-
Events: make(chan Event, 50),
222+
Events: make(chan Event, subscriberEventBufferSize),
192223
done: make(chan struct{}),
193224
}
194225

195226
h.register <- sub
196227
return sub
197228
}
198229

230+
// recordDrop accounts for one dropped event and emits a coalesced warning
231+
// at most once per dropLogInterval per (subscriber, event type). The first
232+
// drop in a window logs immediately so a fresh problem isn't hidden; later
233+
// drops accumulate until the interval elapses, then log with the count.
234+
// Called only from run(); no synchronization on subscriberDrops.
235+
func (h *Hub) recordDrop(subID string, eventType EventType) {
236+
h.recordDropAt(subID, eventType, time.Now())
237+
}
238+
239+
// recordDropAt is the testable form of recordDrop. now is the timestamp the
240+
// caller wants treated as "now" — production code passes time.Now(), tests
241+
// pass a controlled time so throttling behavior is deterministic.
242+
func (h *Hub) recordDropAt(subID string, eventType EventType, now time.Time) {
243+
key := dropKey{subID: subID, eventType: eventType}
244+
245+
agg, ok := h.subscriberDrops[key]
246+
if !ok {
247+
agg = &dropAggregator{firstSeen: now}
248+
h.subscriberDrops[key] = agg
249+
}
250+
agg.count++
251+
252+
if now.Sub(agg.lastLogged) < dropLogInterval {
253+
return
254+
}
255+
256+
h.logger.Warn("subscriber event channel full, dropping event(s)",
257+
"subscriber_id", subID,
258+
"event_type", eventType,
259+
"dropped", agg.count,
260+
"window", now.Sub(agg.firstSeen).Round(time.Millisecond))
261+
agg.lastLogged = now
262+
agg.firstSeen = now
263+
agg.count = 0
264+
}
265+
266+
// cleanupDropTracker removes any drop-aggregator entries for a subscriber
267+
// that has unsubscribed, so the map doesn't grow with churning subscribers.
268+
// Called only from run().
269+
func (h *Hub) cleanupDropTracker(subID string) {
270+
for k := range h.subscriberDrops {
271+
if k.subID == subID {
272+
delete(h.subscriberDrops, k)
273+
}
274+
}
275+
}
276+
199277
// Unsubscribe removes a subscriber.
200278
func (h *Hub) Unsubscribe(sub *Subscriber) {
201279
select {
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package events
2+
3+
import (
4+
"bytes"
5+
"log/slog"
6+
"strings"
7+
"sync"
8+
"testing"
9+
"time"
10+
)
11+
12+
// newTestHub returns a Hub backed by a buffered slog handler so tests can
13+
// inspect emitted warnings.
14+
func newTestHub(t *testing.T) (*Hub, *bytes.Buffer, *sync.Mutex) {
15+
t.Helper()
16+
var buf bytes.Buffer
17+
var mu sync.Mutex
18+
handler := slog.NewTextHandler(&lockedWriter{w: &buf, mu: &mu}, &slog.HandlerOptions{Level: slog.LevelDebug})
19+
logger := slog.New(handler)
20+
return NewHub(logger), &buf, &mu
21+
}
22+
23+
// lockedWriter serialises writes from goroutines that share the slog handler
24+
// so the buffer doesn't get torn output under -race.
25+
type lockedWriter struct {
26+
w *bytes.Buffer
27+
mu *sync.Mutex
28+
}
29+
30+
func (l *lockedWriter) Write(p []byte) (int, error) {
31+
l.mu.Lock()
32+
defer l.mu.Unlock()
33+
return l.w.Write(p)
34+
}
35+
36+
func countLines(buf *bytes.Buffer, mu *sync.Mutex, substr string) int {
37+
mu.Lock()
38+
defer mu.Unlock()
39+
return strings.Count(buf.String(), substr)
40+
}
41+
42+
func TestSubscriberEventBufferIsLarge(t *testing.T) {
43+
hub, _, _ := newTestHub(t)
44+
hub.Start()
45+
defer hub.Stop()
46+
47+
sub := hub.Subscribe("sub-1", "")
48+
if got := cap(sub.Events); got != subscriberEventBufferSize {
49+
t.Fatalf("subscriber event channel capacity = %d, want %d", got, subscriberEventBufferSize)
50+
}
51+
}
52+
53+
func TestRecordDropFirstDropLogsImmediately(t *testing.T) {
54+
hub, buf, mu := newTestHub(t)
55+
t0 := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC)
56+
57+
hub.recordDropAt("sub-1", EventTypeLogsUpdated, t0)
58+
59+
if got := countLines(buf, mu, "subscriber event channel full"); got != 1 {
60+
t.Fatalf("expected 1 warning on first drop, got %d. log=%q", got, buf.String())
61+
}
62+
}
63+
64+
func TestRecordDropCoalescesWithinWindow(t *testing.T) {
65+
hub, buf, mu := newTestHub(t)
66+
t0 := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC)
67+
68+
// First drop logs.
69+
hub.recordDropAt("sub-1", EventTypeLogsUpdated, t0)
70+
// 99 more drops within the window — should not log again.
71+
for i := 1; i <= 99; i++ {
72+
hub.recordDropAt("sub-1", EventTypeLogsUpdated, t0.Add(time.Duration(i)*time.Millisecond))
73+
}
74+
75+
if got := countLines(buf, mu, "subscriber event channel full"); got != 1 {
76+
t.Fatalf("expected 1 coalesced warning, got %d. log=%q", got, buf.String())
77+
}
78+
}
79+
80+
func TestRecordDropEmitsAfterIntervalWithCount(t *testing.T) {
81+
hub, buf, mu := newTestHub(t)
82+
t0 := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC)
83+
84+
hub.recordDropAt("sub-1", EventTypeLogsUpdated, t0)
85+
for i := 1; i <= 49; i++ {
86+
hub.recordDropAt("sub-1", EventTypeLogsUpdated, t0.Add(time.Duration(i)*time.Millisecond))
87+
}
88+
// Cross the interval: the next drop should log again, this time with
89+
// dropped=50 (the 49 coalesced + this one).
90+
hub.recordDropAt("sub-1", EventTypeLogsUpdated, t0.Add(dropLogInterval+time.Millisecond))
91+
92+
if got := countLines(buf, mu, "subscriber event channel full"); got != 2 {
93+
t.Fatalf("expected 2 warnings after crossing interval, got %d. log=%q", got, buf.String())
94+
}
95+
if !strings.Contains(buf.String(), "dropped=50") {
96+
t.Fatalf("expected coalesced count of 50 in second warning, log=%q", buf.String())
97+
}
98+
}
99+
100+
func TestRecordDropPerSubscriberPerEventTypeIsIndependent(t *testing.T) {
101+
hub, buf, mu := newTestHub(t)
102+
t0 := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC)
103+
104+
// Three distinct (subscriber, event_type) streams in the same instant —
105+
// each should produce its own first-drop warning.
106+
hub.recordDropAt("sub-1", EventTypeLogsUpdated, t0)
107+
hub.recordDropAt("sub-1", EventTypeMetricsUpdated, t0)
108+
hub.recordDropAt("sub-2", EventTypeLogsUpdated, t0)
109+
110+
if got := countLines(buf, mu, "subscriber event channel full"); got != 3 {
111+
t.Fatalf("expected 3 independent first-drop warnings, got %d. log=%q", got, buf.String())
112+
}
113+
}
114+
115+
func TestCleanupDropTrackerRemovesSubscriberEntries(t *testing.T) {
116+
hub, _, _ := newTestHub(t)
117+
t0 := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC)
118+
119+
hub.recordDropAt("sub-1", EventTypeLogsUpdated, t0)
120+
hub.recordDropAt("sub-1", EventTypeMetricsUpdated, t0)
121+
hub.recordDropAt("sub-2", EventTypeLogsUpdated, t0)
122+
123+
if got := len(hub.subscriberDrops); got != 3 {
124+
t.Fatalf("expected 3 tracker entries, got %d", got)
125+
}
126+
127+
hub.cleanupDropTracker("sub-1")
128+
129+
if got := len(hub.subscriberDrops); got != 1 {
130+
t.Fatalf("expected 1 tracker entry after cleanup, got %d", got)
131+
}
132+
if _, ok := hub.subscriberDrops[dropKey{subID: "sub-2", eventType: EventTypeLogsUpdated}]; !ok {
133+
t.Fatal("sub-2 entry should have survived cleanup of sub-1")
134+
}
135+
}
136+
137+
func TestSlowSubscriberDropsAreCoalesced(t *testing.T) {
138+
// End-to-end through the broadcast loop: a subscriber that never drains
139+
// will overflow, but we should still emit only one warning per event-type
140+
// during the burst (no clock fast-forwarding here, so we stay inside
141+
// dropLogInterval).
142+
hub, buf, mu := newTestHub(t)
143+
hub.Start()
144+
defer hub.Stop()
145+
146+
sub := hub.Subscribe("slow-sub", "")
147+
// Never read sub.Events.
148+
_ = sub
149+
150+
// Publish well past the buffer size so a flood of drops occurs.
151+
for i := 0; i < subscriberEventBufferSize*3; i++ {
152+
hub.Publish(Event{Type: EventTypeLogsUpdated})
153+
}
154+
155+
// Let the broadcast loop drain.
156+
deadline := time.Now().Add(2 * time.Second)
157+
for time.Now().Before(deadline) {
158+
if countLines(buf, mu, "subscriber event channel full") >= 1 {
159+
break
160+
}
161+
time.Sleep(10 * time.Millisecond)
162+
}
163+
164+
got := countLines(buf, mu, "subscriber event channel full")
165+
if got == 0 {
166+
t.Fatalf("expected at least one drop warning under burst, got none. log=%q", buf.String())
167+
}
168+
// Under the throttle, a single burst should produce far fewer warnings
169+
// than dropped events. Allow some slack but assert it's not 1-per-drop.
170+
if got > 5 {
171+
t.Fatalf("expected coalesced warning(s), got %d (no throttling?). log=%q", got, buf.String())
172+
}
173+
}

0 commit comments

Comments
 (0)