Repository navigation
fix(processor): export the last partial batch on shutdown #924
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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) | ||
| }() | ||
|
|
||
| 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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ... @ |
||
| 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 { | ||
|
|
@@ -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 | ||
|
|
@@ -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") | ||
|
|
||
There was a problem hiding this comment.
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-bufferedbeaconErrchannel, then selects once. On the signal branch it callsstopBeacon()and immediately returnsc.Shutdown(ctx)without ever readingbeaconErr. BecauseBeaconNode.Startreturnsctx.Err()on cancellation, cancellingbeaconCtxalways produces a value the buffered send silently absorbs; any real beacon-side error during the shutdown window is equally lost.SIGTERM/SIGINT reaches the
signalscase.stopBeacon()cancelsbeaconCtx; the beacon goroutine returns (ctx.Err()fromBeaconNode.Start'scase <-ctx.Done(): return ctx.Err(), or an error from an in-flight service start / onReady callback) and sends it into the bufferedbeaconErr. Start returnsc.Shutdown(ctx)and never readsbeaconErr.The beacon node's reported error never reaches
cmd/cannon.go, which doesif 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.There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:stopBeacon()(line 201), Start no longer goes straight toShutdown. It first joins the beacon goroutine:select { case err := <-beaconErr: ... case <-time.After(beaconStopTimeout): ... }(lines 206-212).fmt.Errorf("beacon node: %w", err), line 209) and joined withShutdown's error viaerrors.Join(errs...)(line 219). Thecontext.Canceledfilter is correct —BeaconNode.Startreturns barectx.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.c.Shutdown(ctx)(line 214), so deriver/sink teardown no longer overlaps the beacon goroutine in the normal path.cmd/cannon.go:156-158doeslog.Fatal(err)on a non-nilStartresult.One residual nuance, not blocking: if the beacon goroutine doesn't return within
beaconStopTimeout(10s, line 224), Start proceeds toShutdownanyway, 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 90sterminationGracePeriodSeconds), and the buffered channel absorbs the late send without leaking the goroutine.So the original finding no longer holds at this head.
@
8d02879d