fix(CLI-01): Go Events() emits terminal ErrSlowConsumer on overflow

Events()/EventsAfter() previously closed the channel silently when the
16-slot buffer overflowed, indistinguishable from a graceful server end.
Reserve one slot and emit a terminal EventResult wrapping the new exported
ErrSlowConsumer so overflow is always observable. The blocking
SubscribeEvents path (gRPC flow-controlled) is unchanged.

archreview: CLI-01 (P0). Verified: gofmt clean, go build ./..., go test ./... (+ -race) pass.
This commit is contained in:
Joseph Doherty
2026-07-09 05:51:45 -04:00
parent 59856b8c63
commit 1eb00276bc
3 changed files with 101 additions and 4 deletions
+40 -4
View File
@@ -18,6 +18,15 @@ import (
const maxBulkItems = 1000
// eventBufferSize is the number of buffered event slots on the
// Events/EventsAfter (cancel-when-full) results channel.
const eventBufferSize = 16
// eventBufferReservedSlots is the extra capacity reserved beyond
// eventBufferSize so a terminal ErrSlowConsumer result can always be enqueued
// non-blockingly on overflow, even when all data slots are full.
const eventBufferReservedSlots = 1
// EventResult carries either the next ordered event or a terminal stream error.
type EventResult struct {
// Event is the next event from the stream when Err is nil.
@@ -674,11 +683,22 @@ func (s *Session) Write2Raw(ctx context.Context, serverHandle, itemHandle int32,
// Events streams ordered session events until the server ends the stream,
// context cancellation stops Recv, or a terminal error is sent.
//
// The returned channel is buffered. If the consumer falls behind and the buffer
// overflows, the stream is terminated and a final EventResult carrying a
// GatewayError that wraps ErrSlowConsumer is delivered before the channel
// closes. Callers must match it with errors.Is(res.Err, ErrSlowConsumer) to
// distinguish a slow-consumer drop from a graceful server end. Use
// SubscribeEvents for a blocking, backpressured stream that never drops.
func (s *Session) Events(ctx context.Context) (<-chan EventResult, error) {
return s.EventsAfter(ctx, 0)
}
// EventsAfter streams ordered session events after the given worker sequence.
//
// Like Events, the returned channel is buffered and terminates with a final
// EventResult wrapping ErrSlowConsumer (matchable via errors.Is) if the consumer
// falls behind and the buffer overflows, rather than silently closing.
func (s *Session) EventsAfter(ctx context.Context, afterWorkerSequence uint64) (<-chan EventResult, error) {
subscription, err := s.subscribeEventsAfter(ctx, afterWorkerSequence, true)
if err != nil {
@@ -708,7 +728,7 @@ func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence
return nil, err
}
results := make(chan EventResult, 16)
results := make(chan EventResult, eventBufferSize+eventBufferReservedSlots)
done := make(chan struct{})
go func() {
defer close(results)
@@ -756,14 +776,30 @@ func sendEventResult(
cancel context.CancelFunc,
) bool {
if cancelWhenBufferFull {
// Treat the channel as full once the eventBufferSize data slots are
// occupied, keeping eventBufferReservedSlots free for the terminal
// error. This goroutine is the sole producer, so len(results) only
// grows by our own sends and shrinks as the consumer reads; the reserve
// therefore always survives to carry ErrSlowConsumer. A plain buffered
// send would instead consume the reserved slot as ordinary data.
if len(results) >= eventBufferSize {
// The consumer fell behind and the data slots are full. Cancel the
// stream, then use the reserved terminal slot to enqueue a loud
// ErrSlowConsumer result so overflow is always observable rather
// than a silently closed channel indistinguishable from a graceful
// server end.
cancel()
select {
case results <- EventResult{Err: &GatewayError{Op: "stream events", Err: ErrSlowConsumer}}:
default:
}
return false
}
select {
case results <- result:
return true
case <-ctx.Done():
return false
default:
cancel()
return false
}
}