fix(eventbus): deliver events on a bounded per-subscriber worker

The previous fix dispatched each event to every subscriber with a bare
`go safeCall`. That unblocked the dispatch loop, but removed the bus's
backpressure: under a login-attempt flood (which both notifier subscribers
process without rate-limiting) with email/Telegram enabled, every attempt
spawned handler goroutines that each block on network I/O for up to ~30s,
with no bound — a goroutine and outbound-connection storm. It also let a
subscriber's handler run concurrently with itself, racing the Telegram
notifier's lazily-cached hostname.

Give each subscriber its own bounded queue drained by a single worker
goroutine. Dispatch does a non-blocking send per subscriber (dropping only
that subscriber's event when its queue is full), so a slow subscriber still
can't stall the others, concurrency is bounded to one in-flight handler per
subscriber, per-subscriber event order is preserved, and Stop again waits
for in-flight handlers to finish.
This commit is contained in:
MHSanaei
2026-07-15 06:27:26 +02:00
parent eb669e1d83
commit 0061892d87
2 changed files with 98 additions and 21 deletions
+37
View File
@@ -174,6 +174,43 @@ func TestBusBlockingSubscriberDoesNotStallOthers(t *testing.T) {
close(release)
}
func TestBusSubscriberRunsSerially(t *testing.T) {
b := New(16)
defer b.Stop()
var inFlight atomic.Int32
var maxSeen atomic.Int32
var wg sync.WaitGroup
const n = 8
wg.Add(n)
b.Subscribe("serial", func(Event) {
cur := inFlight.Add(1)
for {
m := maxSeen.Load()
if cur <= m || maxSeen.CompareAndSwap(m, cur) {
break
}
}
time.Sleep(5 * time.Millisecond)
inFlight.Add(-1)
wg.Done()
})
for i := 0; i < n; i++ {
b.Publish(Event{Type: EventXrayCrash})
}
select {
case <-waitDone(&wg):
case <-time.After(2 * time.Second):
t.Fatal("subscriber did not process all events")
}
if got := maxSeen.Load(); got != 1 {
t.Fatalf("subscriber ran concurrently with itself: max in-flight = %d, want 1", got)
}
}
func TestBusBufferFull(t *testing.T) {
b := New(2)
defer b.Stop()