Skip to content
45 changes: 25 additions & 20 deletions cli/operator/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package operator
import (
"errors"
"fmt"
"os"
"time"

"github.com/ilyakaznacheev/cleanenv"
Expand Down Expand Up @@ -64,13 +65,12 @@ const maxSafeProposerDelay = 1000 * time.Millisecond

// nodeMode is the resolved operating mode of the node, derived once from ExporterOptions by
// resolveAndValidate so startup can dispatch on a typed value instead of re-deriving the mode
// from ExporterOptions.Enabled / .Mode at each site.
// from ExporterOptions.Enabled at each site.
type nodeMode int

const (
modeOperator nodeMode = iota // not an exporter
modeExporterStandard // exporter, standard tracing
modeExporterArchive // exporter, archive tracing (pre-consensus + consensus)
modeOperator nodeMode = iota // not an exporter
modeExporter // exporter (full duty tracing: pre-consensus + consensus + post-consensus)
)

// resolved carries config-derived state computed by resolveAndValidate (not operator-provided):
Expand Down Expand Up @@ -136,11 +136,10 @@ func (c *config) resolveAndValidate(logger *zap.Logger) (resolved, error) {

// Resolve the operating mode last so a doubly-misconfigured node still surfaces the signing
// or proposer-delay error first.
m, err := resolveMode(c.ExporterOptions)
if err != nil {
return resolved{}, err
res.mode = resolveMode(c.ExporterOptions)
if res.mode != modeOperator {
warnDeprecatedExporterEnv(logger)
}
res.mode = m

return res, nil
}
Expand Down Expand Up @@ -218,20 +217,26 @@ func (c *config) resolveSigning() (resolved, error) {
return res, nil
}

// resolveMode derives the node's operating mode from the exporter options, rejecting an
// unrecognized EXPORTER_MODE up front (fail-fast). A non-exporter node is always modeOperator,
// regardless of the (then-irrelevant) EXPORTER_MODE.
func resolveMode(opts exporter.Options) (nodeMode, error) {
// resolveMode derives the node's operating mode from the exporter options. An enabled exporter
// always runs in full duty-tracing mode; a non-exporter node is modeOperator. (The legacy
// standard/archive EXPORTER_MODE distinction was removed — exporters are always full tracers.)
func resolveMode(opts exporter.Options) nodeMode {
if !opts.Enabled {
return modeOperator, nil
return modeOperator
}
switch opts.Mode {
case exporter.ModeStandard:
return modeExporterStandard, nil
case exporter.ModeArchive:
return modeExporterArchive, nil
default:
return modeOperator, fmt.Errorf("invalid exporter mode %q (must be %q or %q)", opts.Mode, exporter.ModeStandard, exporter.ModeArchive)
return modeExporter
}

// warnDeprecatedExporterEnv flags removed exporter env vars (set via a pre-existing deployment) so
// operators notice a stale value is now ignored: exporters always run full duty tracing, and
// retention moved from slots to EXPORTER_RETAIN_EPOCHS. Best-effort — only env vars are checked,
// not equivalent YAML keys (cleanenv silently drops unknown fields).
func warnDeprecatedExporterEnv(logger *zap.Logger) {
for _, env := range []string{"EXPORTER_MODE", "EXPORTER_RETAIN_SLOTS"} {
if v, ok := os.LookupEnv(env); ok {
logger.Warn("ignoring removed exporter config option; exporters always run full duty tracing — use EXPORTER_RETAIN_EPOCHS for retention",
zap.String("env", env), zap.String("value", v))
}
}
}

Expand Down
67 changes: 21 additions & 46 deletions cli/operator/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,6 @@ import (
const (
testSignerEndpoint = "http://signer:9000"
testOperatorKey = "super-secret-operator-key"

// substring of resolveMode's error for an unrecognized EXPORTER_MODE (kept as a const so
// the repeated assertion doesn't trip goconst).
msgInvalidExporterMode = "invalid exporter mode"
)

func Test_config_load(t *testing.T) {
Expand Down Expand Up @@ -122,8 +118,9 @@ func Test_resolveAndValidate_signingErrorContext(t *testing.T) {
}
}

// Test_resolveAndValidate_mode verifies the operating mode is resolved into the result and that
// an invalid EXPORTER_MODE fails validation up front.
// Test_resolveAndValidate_mode verifies the operating mode is resolved into the result: a
// non-exporter is modeOperator, and an enabled exporter is modeExporter (full duty tracing). The
// legacy standard/archive EXPORTER_MODE validation was removed along with the mode itself.
func Test_resolveAndValidate_mode(t *testing.T) {
t.Run("non-exporter -> modeOperator", func(t *testing.T) {
c := config{}
Expand All @@ -133,22 +130,12 @@ func Test_resolveAndValidate_mode(t *testing.T) {
require.Equal(t, modeOperator, res.mode)
})

t.Run("exporter archive -> modeExporterArchive", func(t *testing.T) {
t.Run("exporter -> modeExporter", func(t *testing.T) {
c := config{}
c.ExporterOptions.Enabled = true
c.ExporterOptions.Mode = exporter.ModeArchive
res, err := c.resolveAndValidate(zap.NewNop())
require.NoError(t, err)
require.Equal(t, modeExporterArchive, res.mode)
})

t.Run("invalid exporter mode -> error", func(t *testing.T) {
c := config{}
c.ExporterOptions.Enabled = true
c.ExporterOptions.Mode = "bogus"
_, err := c.resolveAndValidate(zap.NewNop())
require.Error(t, err)
require.Contains(t, err.Error(), msgInvalidExporterMode)
require.Equal(t, modeExporter, res.mode)
})
}

Expand Down Expand Up @@ -425,36 +412,24 @@ func Test_resolveSigning(t *testing.T) {
}
}

// Test_resolveMode covers operating-mode resolution and the fail-fast rejection of an
// unrecognized EXPORTER_MODE.
// Test_resolveMode covers operating-mode resolution: an enabled exporter resolves to modeExporter
// (full duty tracing), everything else to modeOperator.
func Test_resolveMode(t *testing.T) {
tests := []struct {
name string
enabled bool
mode string
want nodeMode
wantErr string
}{
{name: "not exporter -> operator", enabled: false, mode: "", want: modeOperator},
{name: "not exporter ignores mode -> operator", enabled: false, mode: exporter.ModeArchive, want: modeOperator},
{name: "exporter standard", enabled: true, mode: exporter.ModeStandard, want: modeExporterStandard},
{name: "exporter archive", enabled: true, mode: exporter.ModeArchive, want: modeExporterArchive},
{name: "exporter invalid -> error", enabled: true, mode: "bogus", wantErr: msgInvalidExporterMode},
{name: "exporter empty mode -> error", enabled: true, mode: "", wantErr: msgInvalidExporterMode},
}
require.Equal(t, modeOperator, resolveMode(exporter.Options{Enabled: false}))
require.Equal(t, modeExporter, resolveMode(exporter.Options{Enabled: true}))
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveMode(exporter.Options{Enabled: tt.enabled, Mode: tt.mode})
if tt.wantErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
// Test_warnDeprecatedExporterEnv verifies a stale, now-removed exporter env var is flagged rather
// than silently ignored.
func Test_warnDeprecatedExporterEnv(t *testing.T) {
t.Setenv("EXPORTER_MODE", "archive")
t.Setenv("EXPORTER_RETAIN_SLOTS", "50400")

core, logs := observer.New(zapcore.WarnLevel)
warnDeprecatedExporterEnv(zap.New(core))

require.Equal(t, 1, logs.FilterField(zap.String("env", "EXPORTER_MODE")).Len())
require.Equal(t, 1, logs.FilterField(zap.String("env", "EXPORTER_RETAIN_SLOTS")).Len())
}

func Test_warnIfSSVAPIAddressUnset(t *testing.T) {
Expand Down
54 changes: 11 additions & 43 deletions cli/operator/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"

"github.com/attestantio/go-eth2-client/spec/phase0"
spectypes "github.com/ssvlabs/ssv-spec/types"
"go.uber.org/zap"

Expand Down Expand Up @@ -359,12 +357,6 @@ func newNode(ctx context.Context, cfg *config, logger *zap.Logger, res resolved,
})
}

if res.mode == modeExporterStandard {
retain := cfg.ExporterOptions.RetainSlots
threshold := networkConfig.EstimatedCurrentSlot()
initSlotPruning(ctx, storageMap, slotTickerProvider, threshold, retain)
}

fixedSubnets, err := networkcommons.SubnetsFromString(cfg.P2pNetworkConfig.Subnets)
if err != nil {
return nil, fmt.Errorf("failed to parse fixed subnets: %w", err)
Expand All @@ -383,12 +375,16 @@ func newNode(ctx context.Context, cfg *config, logger *zap.Logger, res resolved,
metadata.WithSyncInterval(cfg.SSVOptions.ValidatorOptions.MetadataUpdateInterval),
)

// Exporter duty tracing. An invalid EXPORTER_MODE is rejected up front by resolveAndValidate,
// so res.mode here is always one of the known modes.
// Exporter duty tracing. Exporters always run full duty tracing — collecting pre-consensus,
// consensus, and post-consensus steps and serving them via the read API. (The legacy
// standard/archive EXPORTER_MODE distinction was removed; "standard" was a strict, and broken,
// subset.) RetainEpochs bounds on-disk trace history; 0 (default) retains indefinitely.
var collector *dutytracer.Collector
switch res.mode {
case modeExporterArchive:
logger.Info("exporter mode: archive")
if res.isExporter() {
retainSlots := cfg.ExporterOptions.RetainEpochs * networkConfig.SlotsPerEpoch
logger.Info("exporter enabled (full duty tracing)",
zap.Uint64("retain_epochs", cfg.ExporterOptions.RetainEpochs),
zap.Uint64("retain_slots", retainSlots))
dstore := &dutytracer.DutyTraceStoreMetrics{
Store: dutytracestore.New(db),
}
Expand All @@ -397,12 +393,8 @@ func newNode(ctx context.Context, cfg *config, logger *zap.Logger, res resolved,
dstore, networkConfig.Beacon, decidedStreamPublisherFn,
dutyStore)

go collector.Start(ctx, slotTickerProvider)
go collector.Start(ctx, slotTickerProvider, retainSlots)
cfg.SSVOptions.ExporterRead = exporter2.NewExporter(logger, storageMap, collector, nodeStorage.ValidatorStore())
case modeExporterStandard:
logger.Info("exporter mode: standard")
case modeOperator:
// not an exporter: no duty-trace collector
}

doppelgangerHandler := buildDoppelganger(logger, cfg, res, networkConfig.Beacon, consensusClient, validatorProvider, slotTickerProvider)
Expand Down Expand Up @@ -597,7 +589,7 @@ func (n *node) start() error {
Shares: n.nodeStorage.Shares(),
},
hexporter.NewExporter(n.logger, n.storageMap, n.collector, n.nodeStorage.ValidatorStore()),
n.mode == modeExporterArchive,
n.mode == modeExporter,
)
_, apiServeErr, err := apiServer.Start(n.ctx)
if err != nil {
Expand Down Expand Up @@ -664,30 +656,6 @@ func setupOperatorDataStore(
return operatordatastore.New(operatorData), nil
}

func initSlotPruning(ctx context.Context, stores *ibftstorage.ParticipantStores, slotTickerProvider slotticker.Provider, slot phase0.Slot, retain uint64) {
var wg sync.WaitGroup

threshold := slot - phase0.Slot(retain)

// async perform initial slot gc
_ = stores.Each(func(_ spectypes.BeaconRole, store ibftstorage.ParticipantStore) error {
wg.Add(1)
go func() {
defer wg.Done()
store.Prune(ctx, threshold)
}()
return nil
})

wg.Wait()

// start background job for removing old slots on every tick
_ = stores.Each(func(_ spectypes.BeaconRole, store ibftstorage.ParticipantStore) error {
go store.PruneContinuously(ctx, slotTickerProvider, phase0.Slot(retain))
return nil
})
}

// buildDoppelganger returns the node's doppelganger-protection provider: a no-op for exporter nodes
// (and for operator nodes with protection disabled), or a real handler when protection is enabled.
func buildDoppelganger(
Expand Down
24 changes: 7 additions & 17 deletions cli/operator/node_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (

"github.com/ssvlabs/ssv/doppelganger"
"github.com/ssvlabs/ssv/eth/executionclient"
"github.com/ssvlabs/ssv/exporter"
"github.com/ssvlabs/ssv/hprobe"
"github.com/ssvlabs/ssv/network"
"github.com/ssvlabs/ssv/networkconfig"
Expand Down Expand Up @@ -126,19 +125,15 @@ func Test_newNode_wiresOperatorNode(t *testing.T) {
}
}

// Test_newNode_wiresExporterNode mirrors the operator smoke test for the exporter paths: with no
// signing identity, it asserts newNode() wires the graph for both exporter modes and that the
// mode-specific divergences hold — no key manager in either, and a duty-trace collector only in
// archive mode.
// Test_newNode_wiresExporterNode mirrors the operator smoke test for the exporter path: with no
// signing identity, it asserts newNode() wires the graph for an exporter node — no key manager,
// and a duty-trace collector (exporters always run full duty tracing).
func Test_newNode_wiresExporterNode(t *testing.T) {
for _, tc := range []struct {
name string
mode nodeMode
exporterMode string
wantCollector bool
name string
mode nodeMode
}{
{name: "standard", mode: modeExporterStandard, exporterMode: exporter.ModeStandard, wantCollector: false},
{name: "archive", mode: modeExporterArchive, exporterMode: exporter.ModeArchive, wantCollector: true},
{name: "exporter", mode: modeExporter},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
Expand All @@ -152,7 +147,6 @@ func Test_newNode_wiresExporterNode(t *testing.T) {
cfg := &config{}
cfg.DBOptions.Path = t.TempDir()
cfg.ExporterOptions.Enabled = true
cfg.ExporterOptions.Mode = tc.exporterMode
cfg.MetricsAPIPort = 0
cfg.SSVAPIPort = 0
cfg.WsAPIPort = 0
Expand All @@ -170,11 +164,7 @@ func Test_newNode_wiresExporterNode(t *testing.T) {
require.NotNil(t, a.operatorNode)
require.Nil(t, a.keyManager, "exporter nodes have no key manager")

if tc.wantCollector {
require.NotNil(t, a.collector, "archive mode wires a duty-trace collector")
} else {
require.Nil(t, a.collector, "standard mode has no duty-trace collector")
}
require.NotNil(t, a.collector, "exporter wires a duty-trace collector")

require.NoError(t, a.Close())
})
Expand Down
10 changes: 2 additions & 8 deletions exporter/opts.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
package exporter

type Options struct {
Enabled bool `yaml:"Enabled" env:"EXPORTER" env-default:"false" env-description:"Enable exporter mode to track post-consensus participations"`
Mode string `yaml:"Mode" env:"EXPORTER_MODE" env-default:"standard" env-description:"Set to 'archive' to also track pre-consensus and consensus steps. Defaults to 'standard'"`
RetainSlots uint64 `yaml:"RetainSlots" env:"EXPORTER_RETAIN_SLOTS" env-default:"50400" env-description:"Number of slots to retain in export data"`
Enabled bool `yaml:"Enabled" env:"EXPORTER" env-default:"false" env-description:"Enable exporter mode to track validator duties and network consensus participation (full duty tracing)"`
RetainEpochs uint64 `yaml:"RetainEpochs" env:"EXPORTER_RETAIN_EPOCHS" env-default:"0" env-description:"Best-effort retention: prune on-disk duty traces older than this many epochs. 0 (default) retains indefinitely. Enforced forward from process start — history that ages out while the node is down is not reclaimed after a restart"`
}

const (
ModeArchive = "archive"
ModeStandard = "standard"
)
Loading