Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 78 additions & 18 deletions pkg/cannon/cannon.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ type Cannon struct {

scheduler gocron.Scheduler

// deriversMu guards eventDerivers and stopping, which the beacon's OnReady callback and Shutdown both touch.
deriversMu sync.Mutex
eventDerivers []deriver.EventDeriver
stopping bool

coordinatorClient *coordinator.Client

Expand Down Expand Up @@ -178,49 +181,94 @@ func (c *Cannon) Start(ctx context.Context) error {
c.log.WithField("network", c.Config.Ethereum.OverrideNetworkName).WithContext(ctx).Info("Overriding network name")
}

if err := c.beacon.Start(ctx); err != nil {
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)

// The beacon node runs until it fails, so watch the signal alongside it; cancelling it stops the derivers.
beaconCtx, stopBeacon := context.WithCancel(ctx)
defer stopBeacon()

beaconErr := make(chan error, 1)

go func() {
beaconErr <- c.beacon.Start(beaconCtx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Signal path discards the beacon node's error after stopBeacon(), making beacon shutdown failures invisible to the caller

Start launches c.beacon.Start(beaconCtx) in a goroutine writing to a 1-buffered beaconErr channel, then selects once. On the signal branch it calls stopBeacon() and immediately returns c.Shutdown(ctx) without ever reading beaconErr. Because BeaconNode.Start returns ctx.Err() on cancellation, cancelling beaconCtx always produces a value the buffered send silently absorbs; any real beacon-side error during the shutdown window is equally lost.

SIGTERM/SIGINT reaches the signals case. stopBeacon() cancels beaconCtx; the beacon goroutine returns (ctx.Err() from BeaconNode.Start's case <-ctx.Done(): return ctx.Err(), or an error from an in-flight service start / onReady callback) and sends it into the buffered beaconErr. Start returns c.Shutdown(ctx) and never reads beaconErr.

The beacon node's reported error never reaches cmd/cannon.go, which does if err := cannon.Start(...); err != nil { log.Fatal(err) }. A beacon-node failure that surfaces during shutdown (including a genuine startup/subscription/onReady-callback failure racing the signal) yields a nil-ish Start result and, if Shutdown succeeds, a clean exit. Additionally, Start is not joined before Shutdown cancels and tears down derivers/sinks while the beacon goroutine may still be running, so ordering between beacon teardown and deriver/sink teardown is unmanaged.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8d02879: after a signal, Start cancels the beacon node and waits for its result (bounded to 10s) before calling Shutdown, so deriver and sink teardown no longer overlaps it. A non-cancellation beacon error is joined with Shutdown's error and reaches cmd/cannon.go.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — addressed by 8d02879, which is the PR head. Reading the code now at pkg/cannon/cannon.go:201-219:

  • After the signal branch calls stopBeacon() (line 201), Start no longer goes straight to Shutdown. It first joins the beacon goroutine: select { case err := <-beaconErr: ... case <-time.After(beaconStopTimeout): ... } (lines 206-212).
  • A non-cancellation beacon error is wrapped (fmt.Errorf("beacon node: %w", err), line 209) and joined with Shutdown's error via errors.Join(errs...) (line 219). The context.Canceled filter is correct — BeaconNode.Start returns bare ctx.Err() on cancellation (pkg/cannon/ethereum/beacon.go:302-305), while real failures (service start, network detection, onReady callbacks) are wrapped errors that still surface.
  • The join happens before c.Shutdown(ctx) (line 214), so deriver/sink teardown no longer overlaps the beacon goroutine in the normal path.
  • The error does reach the caller: cmd/cannon.go:156-158 does log.Fatal(err) on a non-nil Start result.

One residual nuance, not blocking: if the beacon goroutine doesn't return within beaconStopTimeout (10s, line 224), Start proceeds to Shutdown anyway, so teardown overlap is still possible on the timeout path — but that's the deliberate bounded wait (comment at lines 223-224 ties the 10s + 75s budget to the 90s terminationGracePeriodSeconds), and the buffered channel absorbs the late send without leaking the goroutine.

So the original finding no longer holds at this head.


@ 8d02879d

}()

select {
case err := <-beaconErr:
return err
case sig := <-signals:
c.log.WithContext(ctx).Printf("Caught signal: %v", sig)
}

cancel := make(chan os.Signal, 1)
signal.Notify(cancel, syscall.SIGTERM, syscall.SIGINT)
stopBeacon()

sig := <-cancel
c.log.WithContext(ctx).Printf("Caught signal: %v", sig)
var errs []error

// Join the beacon node before the derivers and sinks it feeds are torn down.
select {
case err := <-beaconErr:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Shutdown can race the beacon's OnReady callback goroutine

beaconErr is delivered when BeaconNode.Start returns, but the goroutine inside it that runs the OnReady callbacks is never joined: it assigns c.eventDerivers (cannon.go:1219) and spawns startDeriverWhenReady goroutines that log.Fatal on failure (cannon.go:1240). If the inner go-eth2-client Start doesn't return promptly on cancellation (or the 10s timeout fires), a SIGTERM during beacon startup has Shutdown reading c.eventDerivers concurrently with the callback writing it (data race) and can start derivers into sinks being stopped — a deriver start failure would Fatal-exit the process before the flush. Untraced: go-eth2-client's Start behavior on cancellation (module cache unavailable); the window requires shutdown during the beacon startup window, which is why this is a concern rather than a blocker.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0704280: a mutex guards eventDerivers, Shutdown marks the cannon as stopping so a late OnReady callback starts nothing, and a deriver start that fails because the beacon context was cancelled returns instead of calling Fatal.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...


@ 07042800

if err != nil && !errors.Is(err, context.Canceled) {
errs = append(errs, fmt.Errorf("beacon node: %w", err))
}
case <-time.After(beaconStopTimeout):
c.log.WithContext(ctx).Warn("Beacon node did not stop in time")
}

if err := c.Shutdown(ctx); err != nil {
return err
errs = append(errs, err)
}

return nil
return errors.Join(errs...)
}

const (
// beaconStopTimeout and shutdownTimeout together stay below the chart's 90s terminationGracePeriodSeconds.
beaconStopTimeout = 10 * time.Second
shutdownTimeout = 75 * time.Second
)

// Shutdown stops the producers first and then flushes the sinks, logging failures so one cannot skip the rest.
func (c *Cannon) Shutdown(ctx context.Context) error {
c.log.WithContext(ctx).Printf("Shutting down")

for _, sink := range c.sinks {
if err := sink.Stop(ctx); err != nil {
return err
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownTimeout)
defer cancel()

var errs []error

if err := c.scheduler.Shutdown(); err != nil {
errs = append(errs, fmt.Errorf("scheduler: %w", err))
}

c.deriversMu.Lock()
c.stopping = true
derivers := c.eventDerivers
c.deriversMu.Unlock()

for _, deriver := range derivers {
if err := deriver.Stop(ctx); err != nil {
errs = append(errs, fmt.Errorf("deriver %s: %w", deriver.Name(), err))
}
}

for _, fun := range c.shutdownFuncs {
if err := fun(ctx); err != nil {
return err
errs = append(errs, err)
}
}

if err := c.scheduler.Shutdown(); err != nil {
return err
for _, sink := range c.sinks {
if err := sink.Stop(ctx); err != nil {
errs = append(errs, fmt.Errorf("sink %s: %w", sink.Name(), err))
}
}

for _, deriver := range c.eventDerivers {
if err := deriver.Stop(ctx); err != nil {
return err
}
for _, err := range errs {
c.log.WithError(err).WithContext(ctx).Error("Shutdown step failed")
}

return nil
return errors.Join(errs...)
}

func (c *Cannon) ServeMetrics(ctx context.Context) error {
Expand Down Expand Up @@ -1176,6 +1224,14 @@ func (c *Cannon) startBeaconBlockProcessor(ctx context.Context) error {
)
}

c.deriversMu.Lock()
defer c.deriversMu.Unlock()

// Derivers started after Shutdown would feed sinks that are already stopping.
if c.stopping {
return nil
}

c.eventDerivers = eventDerivers

// Refresh the spec every epoch
Expand All @@ -1195,6 +1251,10 @@ func (c *Cannon) startBeaconBlockProcessor(ctx context.Context) error {

go func() {
if err := c.startDeriverWhenReady(ctx, d); err != nil {
if ctx.Err() != nil {
return
}

c.log.
WithField("deriver", d.Name()).
WithError(err).WithContext(ctx).Fatal("Failed to start deriver")
Expand Down
17 changes: 17 additions & 0 deletions pkg/output/http/exporter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,23 @@ func TestHTTPSinkProcessorAttemptsSparseGroupsAfterEarlierGroupFailure(t *testin
assert.Equal(t, blockSpan.SpanContext().TraceID(), spanContextFromHTTPTraceparent(requests[2].traceparent).TraceID())
}

func TestHTTPSinkStopExportsQueuedEvents(t *testing.T) {
recorder := startRecordingHTTPServer(t)
sink := newTestHTTPSink(t, recorder.server.URL, 512)

require.NoError(t, sink.Start(context.Background()))

for i := range 3 {
require.NoError(t, sink.HandleNewDecoratedEvent(context.Background(), newHTTPTestEvent(xatu.Event_BEACON_API_ETH_V1_EVENTS_BLOCK, fmt.Sprintf("id-%d", i))))
}

require.NoError(t, sink.Stop(context.Background()))

requests := recorder.snapshotRequests()
require.Len(t, requests, 1)
assert.Equal(t, []string{"id-0", "id-1", "id-2"}, requests[0].eventIDs)
}

func startRecordingHTTPServer(t *testing.T) *recordingHTTPServer {
t.Helper()

Expand Down
122 changes: 44 additions & 78 deletions pkg/processor/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"runtime"
"sort"
"strings"
"sync"
Expand Down Expand Up @@ -217,11 +216,13 @@ type BatchItemProcessor[T any] struct {
batchCh chan []*TraceableItem[T]
name string

timer *time.Timer
stopWait sync.WaitGroup
stopOnce sync.Once
stopCh chan struct{}
stopWorkersCh chan struct{}
timer *time.Timer
stopWait sync.WaitGroup
stopOnce sync.Once
stopCh chan struct{}

// queueMu is held for reading while an item is sent to the queue, so Shutdown can close it safely.
queueMu sync.RWMutex

// Metrics
metrics *Metrics
Expand Down Expand Up @@ -285,16 +286,15 @@ func NewBatchItemProcessor[T any](exporter ItemExporter[T], name string, log obs
}

bvp := BatchItemProcessor[T]{
e: exporter,
o: o,
log: log,
name: name,
metrics: metrics,
timer: time.NewTimer(o.BatchTimeout),
queue: make(chan *TraceableItem[T], o.MaxQueueSize),
batchCh: make(chan []*TraceableItem[T], o.Workers),
stopCh: make(chan struct{}),
stopWorkersCh: make(chan struct{}),
e: exporter,
o: o,
log: log,
name: name,
metrics: metrics,
timer: time.NewTimer(o.BatchTimeout),
queue: make(chan *TraceableItem[T], o.MaxQueueSize),
batchCh: make(chan []*TraceableItem[T], o.Workers),
stopCh: make(chan struct{}),
}

bvp.log.WithFields(
Expand All @@ -311,21 +311,27 @@ func NewBatchItemProcessor[T any](exporter ItemExporter[T], name string, log obs
return &bvp, nil
}

// Start launches the batch builder and workers; exports ignore ctx's cancellation so Shutdown can still flush.
func (bvp *BatchItemProcessor[T]) Start(ctx context.Context) {
bvp.stopWait.Add(bvp.o.Workers)
bvp.stopWait.Add(bvp.o.Workers + 1)

bvp.metrics.SetWorkerCount(bvp.name, float64(bvp.o.Workers))

bvp.log.WithContext(ctx).Infof("Starting %d workers for %s", bvp.o.Workers, bvp.name)

exportCtx := context.WithoutCancel(ctx)

for i := 0; i < bvp.o.Workers; i++ {
go func(num int) {
defer bvp.stopWait.Done()
bvp.worker(ctx, num)

bvp.worker(exportCtx, num)
}(i)
}

go func() {
defer bvp.stopWait.Done()

bvp.batchBuilder(ctx)
bvp.log.WithContext(ctx).Info("Batch builder exited")
}()
Expand Down Expand Up @@ -453,7 +459,7 @@ func (bvp *BatchItemProcessor[T]) exportWithTimeout(ctx context.Context, itemsBa
return nil
}

// Shutdown shuts down the batch item processor.
// Shutdown rejects new writes, exports every queued item and shuts the exporter down, or returns ctx's error first.
func (bvp *BatchItemProcessor[T]) Shutdown(ctx context.Context) error {
var err error

Expand All @@ -464,14 +470,15 @@ func (bvp *BatchItemProcessor[T]) Shutdown(ctx context.Context) error {

close(bvp.stopCh)

bvp.timer.Stop()

bvp.drainQueue()

close(bvp.stopWorkersCh)
// Closing the queue makes the builder send its last batch and close batchCh; workers exit once it drains.
bvp.queueMu.Lock()
close(bvp.queue)
bvp.queueMu.Unlock()

bvp.stopWait.Wait()

bvp.timer.Stop()

if bvp.e != nil {
if err = bvp.e.Shutdown(ctx); err != nil {
bvp.log.WithError(err).WithContext(ctx).Error("failed to shutdown processor")
Expand Down Expand Up @@ -552,15 +559,14 @@ func (bvp *BatchItemProcessor[T]) batchBuilder(ctx context.Context) {

var batch []*TraceableItem[T]

defer close(bvp.batchCh)

for {
select {
case <-bvp.stopWorkersCh:
log.WithContext(ctx).Info("Stopping batch builder")
return
case item, ok := <-bvp.queue:
if !ok {
// Channel is closed, send any remaining items in the batch for processing
// before shutting down.
log.WithContext(ctx).Info("Stopping batch builder")

if len(batch) > 0 {
bvp.sendBatch(batch, "shutdown")
}
Expand Down Expand Up @@ -602,64 +608,24 @@ func (bvp *BatchItemProcessor[T]) sendBatch(batch []*TraceableItem[T], reason st
log.Tracef("Batch sent to batch channel")
}

// worker exports batches until the batch builder closes batchCh.
func (bvp *BatchItemProcessor[T]) worker(ctx context.Context, number int) {
for {
select {
case <-bvp.stopWorkersCh:
bvp.log.WithContext(ctx).Infof("Stopping worker %d", number)

return
case batch := <-bvp.batchCh:
bvp.timer.Reset(bvp.o.BatchTimeout)

if err := bvp.exportWithTimeout(ctx, batch); err != nil {
bvp.log.WithError(err).WithContext(ctx).Error("failed to export items")
}
for batch := range bvp.batchCh {
bvp.timer.Reset(bvp.o.BatchTimeout)

bvp.metrics.SetItemsQueued(bvp.name, float64(len(bvp.queue)))
if err := bvp.exportWithTimeout(ctx, batch); err != nil {
bvp.log.WithError(err).WithContext(ctx).Error("failed to export items")
}
}
}

func (bvp *BatchItemProcessor[T]) drainQueue() {
bvp.log.Info("Draining queue: waiting for the batch builder to process remaining items")

// First wait for queue to be processed
for len(bvp.queue) > 0 {
time.Sleep(10 * time.Millisecond)
}

bvp.log.Info("Draining queue: waiting for workers to finish processing batches")

// Then wait for any in-flight batches
for len(bvp.batchCh) > 0 {
time.Sleep(10 * time.Millisecond)
}

bvp.log.Info("Draining queue: all items processed")

close(bvp.queue)
}

func recoverSendOnClosedChan() {
x := recover()

switch err := x.(type) {
case nil:
return
case runtime.Error:
if err.Error() == "send on closed channel" {
return
}
bvp.metrics.SetItemsQueued(bvp.name, float64(len(bvp.queue)))
}

panic(x)
bvp.log.WithContext(ctx).Infof("Stopping worker %d", number)
}

func (bvp *BatchItemProcessor[T]) enqueueOrDrop(ctx context.Context, item *TraceableItem[T]) error {
// This ensures the bvp.queue<- below does not panic as the
// processor shuts down.
defer recoverSendOnClosedChan()
bvp.queueMu.RLock()
defer bvp.queueMu.RUnlock()

select {
case <-bvp.stopCh:
Expand Down
Loading
Loading