180 lines
4.3 KiB
Go
180 lines
4.3 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"go.uber.org/zap"
|
|
|
|
"iop/apps/node/internal/transport"
|
|
"iop/packages/go/config"
|
|
"iop/packages/go/observability"
|
|
)
|
|
|
|
// runtimeSupervisor owns the node's single active edge connection and the
|
|
// reconnect loop. It is created once per fx lifecycle and drives
|
|
// connect/reconnect/shutdown. The mutex guards current across the reconnect
|
|
// goroutine and the OnStop hook.
|
|
type runtimeSupervisor struct {
|
|
cfg *config.NodeConfig
|
|
logger *zap.Logger
|
|
dialer DialFunc
|
|
sleeper func(ctx context.Context, d time.Duration)
|
|
shutdowner fx.Shutdowner
|
|
|
|
mu sync.Mutex
|
|
current *runtimeOwner
|
|
cancelSupervisor context.CancelFunc
|
|
}
|
|
|
|
// start establishes the initial connection, launches the reconnect supervisor
|
|
// goroutine, and starts the metrics server. It maps a connect failure to the
|
|
// same "bootstrap: %w" error the OnStart hook returned before extraction.
|
|
func (s *runtimeSupervisor) start(ctx context.Context) error {
|
|
owner, err := connectRuntime(ctx, s.cfg, s.logger, s.dialer)
|
|
if err != nil {
|
|
return fmt.Errorf("bootstrap: %w", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.current = owner
|
|
s.mu.Unlock()
|
|
|
|
supCtx, cancel := context.WithCancel(context.Background())
|
|
s.cancelSupervisor = cancel
|
|
|
|
go s.run(supCtx, owner.sess)
|
|
|
|
go func() {
|
|
if err := observability.ServeMetrics(s.cfg.Metrics.Port); err != nil {
|
|
s.logger.Warn("metrics server exited", zap.Error(err))
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// run waits for the active session to end, then drives a reconnect. It exits on
|
|
// supervisor cancellation, a local shutdown, or when reconnect returns nil
|
|
// (cancelled or exhausted).
|
|
func (s *runtimeSupervisor) run(supCtx context.Context, sess *transport.Session) {
|
|
for {
|
|
select {
|
|
case <-supCtx.Done():
|
|
return
|
|
case <-sess.Done():
|
|
}
|
|
|
|
// Guard against context cancellation racing with disconnect.
|
|
select {
|
|
case <-supCtx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
if sess.IsLocalShutdown() {
|
|
return
|
|
}
|
|
|
|
newOwner := s.reconnect(supCtx)
|
|
if newOwner == nil {
|
|
return
|
|
}
|
|
sess = newOwner.sess
|
|
}
|
|
}
|
|
|
|
// reconnect runs the bounded reconnect loop. On success it swaps in the new
|
|
// owner and returns it. On supervisor cancellation it returns nil without
|
|
// changing state. On exhaustion it closes the current owner, requests node
|
|
// shutdown, and returns nil. The attempt/interval defaults, sleep, dial timeout,
|
|
// and logging match the prior inline loop.
|
|
func (s *runtimeSupervisor) reconnect(supCtx context.Context) *runtimeOwner {
|
|
maxAttempts := s.cfg.Reconnect.MaxAttempts
|
|
if maxAttempts <= 0 {
|
|
maxAttempts = 10
|
|
}
|
|
intervalSec := s.cfg.Reconnect.IntervalSec
|
|
if intervalSec < 0 {
|
|
intervalSec = 10
|
|
}
|
|
|
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
|
select {
|
|
case <-supCtx.Done():
|
|
return nil
|
|
default:
|
|
}
|
|
|
|
s.sleeper(supCtx, time.Duration(intervalSec)*time.Second)
|
|
if supCtx.Err() != nil {
|
|
return nil
|
|
}
|
|
|
|
s.logger.Info("reconnecting to edge",
|
|
zap.Int("attempt", attempt),
|
|
zap.Int("max_attempts", maxAttempts),
|
|
zap.Int("interval_sec", intervalSec),
|
|
)
|
|
|
|
dialCtx, dialCancel := context.WithTimeout(supCtx, 30*time.Second)
|
|
o, dialErr := connectRuntime(dialCtx, s.cfg, s.logger, s.dialer)
|
|
dialCancel()
|
|
if dialErr == nil {
|
|
s.swapOwner(o)
|
|
return o
|
|
}
|
|
|
|
s.logger.Warn("reconnect attempt failed",
|
|
zap.Int("attempt", attempt),
|
|
zap.Int("max_attempts", maxAttempts),
|
|
zap.Error(dialErr),
|
|
)
|
|
}
|
|
|
|
s.exhaust(maxAttempts)
|
|
return nil
|
|
}
|
|
|
|
// swapOwner installs owner as the current connection and closes the previous one.
|
|
func (s *runtimeSupervisor) swapOwner(owner *runtimeOwner) {
|
|
s.mu.Lock()
|
|
prev := s.current
|
|
s.current = owner
|
|
s.mu.Unlock()
|
|
if prev != nil {
|
|
prev.close()
|
|
}
|
|
}
|
|
|
|
// exhaust clears the current owner, logs the exhaustion, and requests a node
|
|
// shutdown with a non-zero exit code.
|
|
func (s *runtimeSupervisor) exhaust(maxAttempts int) {
|
|
s.mu.Lock()
|
|
prev := s.current
|
|
s.current = nil
|
|
s.mu.Unlock()
|
|
if prev != nil {
|
|
prev.close()
|
|
}
|
|
s.logger.Error("reconnect exhausted, shutting down node",
|
|
zap.Int("max_attempts", maxAttempts),
|
|
)
|
|
_ = s.shutdowner.Shutdown(fx.ExitCode(1))
|
|
}
|
|
|
|
// stop cancels the supervisor goroutine and closes the current connection.
|
|
func (s *runtimeSupervisor) stop() {
|
|
if s.cancelSupervisor != nil {
|
|
s.cancelSupervisor()
|
|
}
|
|
s.mu.Lock()
|
|
owner := s.current
|
|
s.current = nil
|
|
s.mu.Unlock()
|
|
if owner != nil {
|
|
owner.close()
|
|
}
|
|
}
|