44 "context"
55 "errors"
66 "fmt"
7+ "sync"
78 "time"
89
910 "github.com/hookdeck/outpost/internal/alert"
@@ -13,11 +14,19 @@ import (
1314 "github.com/hookdeck/outpost/internal/opevents"
1415 "github.com/mikestefanello/batcher"
1516 "go.uber.org/zap"
17+ "golang.org/x/sync/errgroup"
1618)
1719
1820// ErrInvalidLogEntry is returned when a LogEntry is missing required fields.
1921var ErrInvalidLogEntry = errors .New ("invalid log entry: both event and attempt are required" )
2022
23+ // emitTimeout caps a single sink send. It is the system's definition of the
24+ // worst acceptable send latency, and it bounds each entry goroutine's
25+ // lifetime: in-flight work steady-states at arrival rate × this timeout even
26+ // when every send is stuck, and the shutdown drain is bounded by it. Anything
27+ // slower fails into the nack/redelivery path.
28+ const emitTimeout = 5 * time .Second
29+
2130// LogStore defines the interface for persisting log entries.
2231// This is a consumer-defined interface containing only what logmq needs.
2332type LogStore interface {
@@ -82,14 +91,25 @@ type BatchProcessorConfig struct {
8291}
8392
8493// BatchProcessor batches log entries and writes them to the log store, then
85- // runs the alert pipeline per entry — evaluate, act on the verdict (disable,
86- // operator events), dedup replays — serially in the batch loop.
94+ // runs the alert pipeline per entry on its own goroutine — dispatch-and-move-on,
95+ // so a slow eval or sink send never blocks persistence of the next batch.
96+ //
97+ // Entries process in no particular order, including within a destination: the
98+ // consecutive-failure count tolerates approximate order (its store is a set of
99+ // attempt IDs, so counting is idempotent and commutative; only a success/
100+ // failure race around the reset can skew it), and per-process ordering was
101+ // cosmetic anyway with multiple logmq replicas interleaving a destination's
102+ // attempts. See the discussion on the parallelism RFC.
87103type BatchProcessor struct {
88104 ctx context.Context
89105 logger * logging.Logger
90106 logStore LogStore
91107 alerts AlertPipeline
92108 batcher * batcher.Batcher [* mqs.Message ]
109+ // inflight tracks entry goroutines so Shutdown can drain them. Each is
110+ // bounded by emitTimeout, so the wait is bounded too.
111+ inflight sync.WaitGroup
112+ shutdownOnce sync.Once
93113}
94114
95115// NewBatchProcessor creates a new batch processor for log entries.
@@ -130,11 +150,16 @@ func (bp *BatchProcessor) Add(ctx context.Context, msg *mqs.Message) error {
130150 return nil
131151}
132152
133- // Shutdown gracefully shuts down the batch processor. The batcher flushes
134- // pending batches, so every buffered message reaches a terminal state before
135- // Shutdown returns.
153+ // Shutdown gracefully shuts down the batch processor: the batcher first
154+ // (flushes pending batches, which may still dispatch entry goroutines), then
155+ // the in-flight entries drain. Every dispatched message reaches a terminal
156+ // state before Shutdown returns, and the drain is bounded by emitTimeout.
157+ // Idempotent.
136158func (bp * BatchProcessor ) Shutdown () {
137- bp .batcher .Shutdown ()
159+ bp .shutdownOnce .Do (func () {
160+ bp .batcher .Shutdown ()
161+ bp .inflight .Wait ()
162+ })
138163}
139164
140165// processBatch processes a batch of messages.
@@ -227,7 +252,11 @@ func (bp *BatchProcessor) processBatch(_ string, msgs []*mqs.Message) {
227252 zap .Int ("count" , len (validMsgs )),
228253 zap .Int64 ("insert_duration_ms" , time .Since (insertStart ).Milliseconds ()))
229254
230- // Run the alert pipeline per persisted entry.
255+ // Spawn one goroutine per persisted entry and return — the batch loop
256+ // never waits on eval or delivery. In-flight goroutines are bounded by
257+ // arrival rate × emitTimeout (each lives at most about one send latency),
258+ // and every fetched message reaches ack/nack well inside the broker's
259+ // visibility window.
231260 for i , entry := range entries {
232261 if bp .alerts .Evaluator == nil {
233262 validMsgs [i ].Ack ()
@@ -243,7 +272,10 @@ func (bp *BatchProcessor) processBatch(_ string, msgs []*mqs.Message) {
243272 continue
244273 }
245274
246- bp .processEntry (bp .ctx , entry , validMsgs [i ])
275+ msg := validMsgs [i ]
276+ bp .inflight .Go (func () {
277+ bp .processEntry (bp .ctx , entry , msg )
278+ })
247279 }
248280}
249281
@@ -302,20 +334,30 @@ func (bp *BatchProcessor) processEntry(ctx context.Context, entry *models.LogEnt
302334 return
303335 }
304336
305- // Deliver the attempt's events. Any failure nacks with nothing marked, so
337+ // Deliver the attempt's events concurrently; arrival order within an
338+ // attempt is not guaranteed. Any failure nacks with nothing marked, so
306339 // redelivery re-runs the attempt in full — events already sent may go out
307340 // again (at-least-once).
341+ g , gctx := errgroup .WithContext (ctx )
308342 for _ , de := range events {
309- if err := bp .send (ctx , de , entry ); err != nil {
310- bp .logger .Ctx (ctx ).Error ("opevent delivery failed" ,
311- zap .Error (err ),
312- zap .String ("topic" , de .event .Topic ),
313- zap .String ("attempt_id" , entry .Attempt .ID ),
314- zap .String ("event_id" , entry .Event .ID ),
315- zap .String ("destination_id" , entry .Destination .ID ))
316- msg .Nack ()
317- return
318- }
343+ g .Go (func () error {
344+ sendCtx , cancel := context .WithTimeout (gctx , emitTimeout )
345+ defer cancel ()
346+ if err := bp .send (sendCtx , de , entry ); err != nil {
347+ bp .logger .Ctx (ctx ).Error ("opevent delivery failed" ,
348+ zap .Error (err ),
349+ zap .String ("topic" , de .event .Topic ),
350+ zap .String ("attempt_id" , entry .Attempt .ID ),
351+ zap .String ("event_id" , entry .Event .ID ),
352+ zap .String ("destination_id" , entry .Destination .ID ))
353+ return err
354+ }
355+ return nil
356+ })
357+ }
358+ if g .Wait () != nil {
359+ msg .Nack ()
360+ return
319361 }
320362
321363 if err := bp .alerts .ProcessedIdemp .MarkProcessed (ctx , key ); err != nil {
@@ -338,10 +380,11 @@ type deliveryEvent struct {
338380}
339381
340382// plan acts on an evaluation and builds the operator events owed for this
341- // attempt — disabled, consecutive_failure, exhausted_retries, sent in slice
342- // order. The disable (a DB write) happens here: it's an action, not a
343- // notification, and it must precede event construction so the payloads carry
344- // the destination's latest state (disabled).
383+ // attempt — disabled, consecutive_failure, exhausted_retries. They are sent
384+ // concurrently, so slice order carries no meaning. The disable (a DB write)
385+ // happens here: it's an action, not a notification, and it must precede event
386+ // construction so the payloads carry the destination's latest state
387+ // (disabled).
345388func (bp * BatchProcessor ) plan (ctx context.Context , eval alert.Evaluation , entry * models.LogEntry ) ([]deliveryEvent , error ) {
346389 if eval .ConsecutiveFailure == nil && ! eval .RetriesExhausted {
347390 return nil , nil
0 commit comments