Skip to content

feat(exporter): remove standard mode, add optional duty-trace retention - #2895

Merged
momosh-ssv merged 4 commits into
fix/exporter-duty-store-blackholefrom
feat/exporter-remove-standard-add-retention
Jun 23, 2026
Merged

feat(exporter): remove standard mode, add optional duty-trace retention#2895
momosh-ssv merged 4 commits into
fix/exporter-duty-store-blackholefrom
feat/exporter-remove-standard-add-retention

Conversation

@y0sher

@y0sher y0sher commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2892. Removes the exporter's standard mode — exporters now always run full duty tracing (the former archive behavior) — and adds an optional, default-off retention window for the on-disk duty-trace store.

Stacked on #2892 (fix/exporter-duty-store-blackhole) so the archive voluntary-exit fix is included. Base will retarget to stage once #2892 merges.

Why

EXPORTER_MODE defaulted to standard, yet standard was a strict — and historically broken — subset of archive:

Every deployed exporter (prod mainnet, stage mainnet, all hoodi exporters) already runs archive. Verified live: the production mainnet exporter handles all duty classes archive does; archive is a functional superset of standard (same decideds endpoint + the trace endpoints). So removing standard deletes a real footgun (a default that silently blackholes) without losing any capability.

What changed

  • exporter.Options: drop Mode / ModeStandard / ModeArchive. Replace the standard-only RetainSlots with RetainEpochs (EXPORTER_RETAIN_EPOCHS, default 0 = retain indefinitely — matches today's archive behavior).
  • cli/operator: node-mode enum collapses to operator / exporter; resolveMode can no longer fail; the duty-trace collector + read API are always wired for exporters; the standard-only participant-store pruning is removed.
  • duties.Scheduler: drop the ArchiveMode option — the Attester handler is registered for every exporter (full tracing), unchanged for operators.
  • validator.Controller: route non-committee messages to the trace collector whenever one is wired (exporter), else the legacy participant path (operator) — nil-safe via traceCollector != nil.
  • dutytracer: new DutyTraceStore.PruneSlot(slot) (single prefix-drop per key group) and a retention loop in Collector.Start that prunes trace data older than RetainEpochs. Pruning is capped per tick and seeds forward on first run — enabling retention does not retroactively sweep pre-existing history.

Compatibility

  • A stale EXPORTER_MODE / RetainSlots in existing configs is simply ignored (unknown fields), so nodes keep booting.
  • The effective default flips from the (broken) standard to full tracing — which is what the fleet already runs.
  • Out of scope (deliberately): the operator/legacy participant-store path and its /v1/exporter/decideds handler are untouched; only the exporter stops using standard mode.

Tests

  • Updated: handler-registration (operator/duties), mode resolution + exporter wiring (cli/operator).
  • Added: TestPruneSlot (store removes all trace kinds for a slot, leaves adjacent slots intact) and TestCollector_pruneExpired (retention cursor: seed-forward, per-tick advance, per-tick cap).
  • go build ./..., go vet, and affected unit suites pass.

Verification status

Unit-tested and built clean. Live deploy verification on a stage exporter is pending (vnet tunnel was down at PR-creation time) — will confirm boot (full tracing), trace endpoints, health, and that the now-ignored EXPORTER_MODE in the deploy template still boots cleanly.

🤖 Generated with Claude Code

The exporter's "standard" mode is removed; exporters now always run full duty
tracing (the former "archive" behavior). Standard mode was the default
(EXPORTER_MODE=standard) yet a strict, and historically broken, subset: it
served only the participant-store-backed /v1/exporter/decideds endpoint and was
the source of the gossip-message blackhole (see #2886). All deployed exporters
already run archive, so this removes a footgun without losing capability —
archive is a superset (it serves the same decideds endpoint plus the trace
endpoints).

Changes:
- exporter.Options: drop Mode/ModeStandard/ModeArchive; replace the standard-only
  RetainSlots with RetainEpochs (EXPORTER_RETAIN_EPOCHS, default 0 = retain
  indefinitely, matching today's archive behavior).
- cli/operator: collapse the node-mode enum to operator/exporter; resolveMode no
  longer fails (no mode string to validate); always wire the duty-trace collector
  and read API for exporters; drop the standard-only participant-store pruning.
- duties.Scheduler: drop the ArchiveMode option; the Attester handler is now
  registered for every exporter (full tracing), as before for operators.
- validator.Controller: route non-committee messages to the trace collector
  whenever one is wired (exporter), else the legacy participant path (operator).
- dutytracer: add DutyTraceStore.PruneSlot and a retention loop in Collector.Start
  that prunes trace data older than RetainEpochs (capped per tick; seeds forward
  on first run, so enabling retention does not retroactively sweep old history).

Config back-compat: a stale EXPORTER_MODE / RetainSlots in existing configs is
ignored (unknown fields), so nodes keep booting; the default flips from the
(broken) standard to full tracing.

Tests: handler-registration, mode resolution, exporter wiring, store PruneSlot,
and collector retention-cursor logic updated/added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@y0sher
y0sher requested review from a team as code owners June 16, 2026 09:09
@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.95181% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.8%. Comparing base (f927ae0) to head (e0ded53).

Files with missing lines Patch % Lines
operator/dutytracer/collector.go 78.9% 6 Missing and 2 partials ⚠️
cli/operator/node.go 85.7% 1 Missing ⚠️
operator/validator/controller.go 0.0% 1 Missing ⚠️

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@greptile-apps

greptile-apps Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR removes the exporter's standard mode (which was historically broken and silently blackholed proposer/sync/exit messages) and makes all exporters run full duty tracing unconditionally. It also adds an optional, default-off retention window (EXPORTER_RETAIN_EPOCHS) backed by a new PruneSlot store method and a retentionState cursor in the collector.

  • Mode simplification: modeExporterStandard/modeExporterArchive collapse into a single modeExporter; resolveMode no longer errors; warnDeprecatedExporterEnv flags stale EXPORTER_MODE/EXPORTER_RETAIN_SLOTS env vars at startup.
  • Retention loop: retentionState in the collector tracks an atomic floor (consulted by collection hot paths to reject expired-slot writes) and a single-goroutine cursor that prunes maxPrunePerTick=64 slots per tick — preventing late messages from resurrecting pruned data.
  • Compatibility: Unknown fields in existing YAML/env configs are silently ignored; effective default changes from broken standard mode to full tracing, matching what the entire fleet already runs.

Confidence Score: 4/5

Safe to merge; the retention feature is best-effort by design and the two open gaps in the pruning cursor (acknowledged in prior review threads) are documented and don't affect correctness of the tracing path itself.

The two previously flagged gaps in the retention cursor — in-memory state lost across restarts leaving expired slots permanently on disk, and the cursor silently advancing past slots whose PruneSlot call returned an error — remain unresolved. The PR explicitly documents them as best-effort behavior, but operators enabling EXPORTER_RETAIN_EPOCHS may see unbounded disk growth despite the setting. All other changes are clean and well-tested.

operator/dutytracer/collector.go — retentionState cursor behavior on restart and on PruneSlot failure; the two open concerns from the prior review threads live here.

Important Files Changed

Filename Overview
cli/operator/config.go Collapses modeExporterStandard/modeExporterArchive into a single modeExporter; resolveMode no longer returns an error; adds warnDeprecatedExporterEnv to flag stale env vars. One stale comment referencing 'standard or archive' remains.
operator/dutytracer/collector.go Adds retentionState struct with atomic floor, per-tick cursor advance capped at maxPrunePerTick=64, and expired() guards in collection hot paths. Two known issues flagged in prior review threads (cursor lost on restart; cursor advances past failed PruneSlot calls) are acknowledged but not yet fixed.
exporter/store/store.go Adds PruneSlot method that drops all four key namespaces for a slot via prefix deletes; key layout analysis confirms no cross-slot contamination with the little-endian slot encoding.
operator/validator/controller.go Replaces explicit Mode==ModeArchive check with traceCollector!=nil, which is the correct nil-safe idiom given the wiring invariant enforced in cli/operator/node.go.
exporter/opts.go Removes Mode/ModeStandard/ModeArchive and RetainSlots; adds RetainEpochs (default 0 = retain indefinitely). Clean removal with no leftover references in this file.
cli/operator/node.go Removes initSlotPruning (standard-mode participant-store pruning) and the standard-mode exporter branch; wires collector unconditionally for all exporters; computes retainSlots from RetainEpochs*SlotsPerEpoch correctly.
operator/duties/scheduler.go Removes ArchiveMode option; NewAttesterHandler is now registered unconditionally for all nodes (operator and exporter), matching the full-tracing intent.
operator/dutytracer/store.go Adds PruneSlot to the DutyTraceStore interface, completing the contract for the retention feature.
operator/dutytracer/store_metrics.go Adds PruneSlot wrapper with timing metric, consistent with the existing pattern for other store methods.
operator/node.go Removes ArchiveMode from SchedulerOptions wiring; the exporter now always passes ExporterMode=true without a separate ArchiveMode flag.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Process start] --> B{ExporterOptions.Enabled?}
    B -- No --> C[modeOperator\nno collector]
    B -- Yes --> D[modeExporter\nfull duty tracing]
    D --> E[Compute retainSlots\nRetainEpochs × SlotsPerEpoch]
    E --> F[Collector.Start\ncollector + retentionState]
    F --> G{retainSlots == 0?}
    G -- Yes --> H[Retain indefinitely\nno pruning]
    G -- No --> I[Per-tick: advance retentionState]
    I --> J[Update atomic floor\nreject expired writes]
    I --> K[Prune cursor up to maxPrunePerTick\nslots per tick]
    C --> L[handleNonCommitteeMessages\nlegacy participant store]
    D --> M[traceCollector.Collect\nfull trace path]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Process start] --> B{ExporterOptions.Enabled?}
    B -- No --> C[modeOperator\nno collector]
    B -- Yes --> D[modeExporter\nfull duty tracing]
    D --> E[Compute retainSlots\nRetainEpochs × SlotsPerEpoch]
    E --> F[Collector.Start\ncollector + retentionState]
    F --> G{retainSlots == 0?}
    G -- Yes --> H[Retain indefinitely\nno pruning]
    G -- No --> I[Per-tick: advance retentionState]
    I --> J[Update atomic floor\nreject expired writes]
    I --> K[Prune cursor up to maxPrunePerTick\nslots per tick]
    C --> L[handleNonCommitteeMessages\nlegacy participant store]
    D --> M[traceCollector.Collect\nfull trace path]
Loading

Reviews (2): Last reviewed commit: "test(exporter): fix lint + raise patch c..." | Re-trigger Greptile

Comment thread operator/dutytracer/collector.go Outdated
Comment on lines 130 to 145
// lastPrunedSlot tracks retention progress; seeded lazily on the first eligible tick so we
// enforce retention going forward without an expensive backfill of pre-existing history.
var lastPrunedSlot phase0.Slot
for {
select {
case <-ctx.Done():
return
case <-ticker.Next():
currentSlot := ticker.Slot()
c.evict(currentSlot)
if retainSlots > 0 {
lastPrunedSlot = c.pruneExpired(currentSlot, phase0.Slot(retainSlots), lastPrunedSlot)
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 In-memory cursor lost on restart — retention gap grows with every deploy

lastPrunedSlot is a local variable initialized to zero on every process start. After a restart, pruneExpired treats the zero value as "first run" and seeds the cursor to the current boundary (currentSlot - retainSlots), skipping all slots that fell outside the window since the last run's pruning stopped. Those slots are never visited again because the forward-only cursor never goes back.

Concretely: if the node was pruning up to slot 1 000 before a restart and comes back at slot 1 200 with retainSlots=160 (10 epochs × 32), slots 1 000–1 039 are permanently skipped. Each rolling deploy repeats this, so storage can grow without bound regardless of the configured RetainEpochs. Persisting the cursor in the KV store (or seeding from the last-known pruned slot on startup) would close this gap.

Comment thread operator/dutytracer/collector.go Outdated
Comment on lines +164 to +170
for s := lastPruned; s < boundary && pruned < maxPerTick; s++ {
if err := c.store.PruneSlot(s); err != nil {
c.logger.Warn("failed to prune expired duty traces", fields.Slot(s), zap.Error(err))
}
pruned++
lastPruned = s + 1
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Pruning cursor advances unconditionally past failed slots

lastPruned is incremented to s + 1 even when c.store.PruneSlot(s) returns an error. A slot whose data cannot be removed (e.g., due to a transient or persistent DB write error) is silently skipped — its data stays on disk indefinitely while the cursor moves on. The warning log is the only signal, and there's no retry path. Under sustained DB pressure, slots pile up past the retention boundary with no recovery mechanism.

@y0sher

y0sher commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Live verification — stage mainnet exporter (node 1002)

Deployed feat/exporter-remove-standard-add-retention (commit 40d2563) to the stage mainnet exporter (ssv-node-exporter-mainnet-1, ~52K mainnet validators) via the bot-stage-ssv-exporter-mainnet-template, which still sets EXPORTER_MODE=archive — now an ignored field.

Check Result
Boots on new commit starting SSV-Node:untagged-40d2563…
Full-tracing config exporter enabled (full duty tracing) retain_epochs=0 retain_slots=0
Back-compat (stale EXPORTER_MODE=archive ignored) ✅ boots cleanly, no error
Handler set PROPOSER, SYNC_COMMITTEE, VOLUNTARY_EXIT, ATTESTER (one each)
No legacy mode log / no skipping duty scheduler ✅ absent
Duty tracer collecting name=dutytracer … quorum reached after flush beacon_role=ATTESTER signers_count=3
panic / FATAL ✅ none
Peers / health ✅ connected, warming up (fresh DB)

dropping buffered pending signatures during eviction appears during the first minutes — a dutytracer warm-up artifact (evicting slots whose lifecycle started before the node did; unchanged eviction code). It is absent on the long-running prod archive exporter. Expected to subside once the node is past warm-up; steady-state resource use should match the archive exporter it replaces (the workload is identical minus the removed dead code).

Retention (RetainEpochs/pruning) is covered by unit tests (TestPruneSlot, TestCollector_pruneExpired); default 0 = retain indefinitely was confirmed live.

Move the duty-trace retention logic out of an ad-hoc cursor threaded through a
method into a small, self-contained tracePruner, and expand its test to cover
every safeguard. Behavior is unchanged; this keeps the retention path
production-grade (instrumented + edge-case-guarded) rather than trimming the
safety logic for brevity:

- underflow guard (currentSlot <= retainSlots) — no unsigned slot wraparound
  early in the chain's life;
- seed-forward on first activation — enabling retention on a live chain does not
  backfill millions of historical (mostly empty) slots;
- per-tick cap (maxPrunePerTick) — a large boundary jump after a pause cannot
  stall the eviction loop;
- cursor catch-up — a coalesced/late tick reclaims skipped slots instead of
  leaking them;
- debug log on prune + duration metric via DutyTraceStoreMetrics.

TestTracePruner exercises disabled, underflow, seed-forward, steady-state, and
capped-catch-up cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@y0sher

y0sher commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Update — retention refactor + steady-state verification (commit 69cd7d8)

Retention logic was refactored into a self-contained, fully-tested tracePruner. Structure is leaner, but the production safeguards are intentionally kept (instrumented + edge-case-guarded, not trimmed for brevity):

  • underflow guard (currentSlot <= retainSlots) — no unsigned slot wraparound early in chain life;
  • seed-forward on first activation — enabling retention on a live chain (slot ~14.5M) does not backfill millions of historical slots;
  • per-tick cap (maxPrunePerTick) — a large boundary jump after a pause can't stall the eviction loop;
  • cursor catch-up — a coalesced/late tick reclaims skipped slots instead of leaking them;
  • debug log on prune + per-op duration metric via DutyTraceStoreMetrics.

TestTracePruner covers: disabled, underflow, seed-forward (no backfill), steady-state single-slot prune, and capped catch-up. The in-memory-cursor limitation (slots expiring during downtime aren't reclaimed after restart) is documented in-code as an accepted bound for best-effort disk retention.

Live re-verification — stage mainnet exporter (node 1002), ~52K mainnet validators

Check Result
Boots on 69cd7d8
Full-tracing config exporter enabled (full duty tracing) retain_epochs=0 retain_slots=0
Handler set ✅ PROPOSER + SYNC_COMMITTEE + VOLUNTARY_EXIT + ATTESTER
Steady-state RSS (52 min uptime) 5.80 GB — in line with the archive exporter it replaces (prod 6.44 GB, stage 6.0–6.3 GB)
Peers ✅ 510
CL/event sync ✅ 100%
Warm-up dropping buffered noise ✅ cleared (0 in last 5m)
Non-benign ERROR logs (last 5m) ✅ 0

Steady-state resource use confirms the expectation: removing standard mode and making the exporter a full tracer costs the same as the archive mode already deployed fleet-wide — the workload is identical minus the deleted dead code.

…n + polish

Independent review (Codex) of the retention work surfaced a real correctness gap
and several improvements:

- Recreation race (should-fix): after PruneSlot deleted a slot, a late message
  took the disk-miss path in getOrCreate{Validator,Committee}Trace and re-saved
  an empty trace, resurrecting the pruned slot. Introduce a single retained-slot
  floor (retentionState, owned by Collector) that the collection and schedule
  paths consult; messages/jobs for slots below the floor are dropped via a new
  errExpiredSlot sentinel (Collect drops it silently). Reachable when
  RetainEpochs is small enough that the floor falls inside the message-validation
  window.
- Refactor: folded the ad-hoc tracePruner cursor into retentionState — one source
  of truth for the floor (drop guard) and the disk-prune cursor.
- Honest semantics (should-fix): EXPORTER_RETAIN_EPOCHS is now documented as
  best-effort, forward-only retention (history aging out during downtime is not
  reclaimed after restart). Persisted-cursor GC left as a follow-up.
- Deprecation visibility (should-fix): warn at startup when removed
  EXPORTER_MODE / EXPORTER_RETAIN_SLOTS env vars are set, so a stale value isn't
  silently ignored.
- Simplification: PruneSlot drops scheduled data with a single sd+slot prefix
  (makeScheduledSlotPrefix) instead of iterating roles.
- Tests: TestRetentionState now covers the floor/expired guard; new
  TestCollector_dropsExpiredSlot proves the hot path refuses expired slots;
  TestPruneSlot covers multiple validator roles. Stale comments fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@y0sher

y0sher commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Independent Codex review + fixes (commit 0319747)

Ran an independent adversarial review (Codex / gpt-5.5). Verdict was "needs-work, no blockers." Findings and how they were addressed:

Fixed

  • Pruned-slot resurrection (correctness): after PruneSlot deleted a slot, a late message took the disk-miss path in getOrCreate{Validator,Committee}Trace and re-saved an empty trace — resurrecting a pruned slot. Reachable when RetainEpochs is small enough that the floor lands inside the message-validation window. Fixed with a single retained-slot floor (retentionState, owned by Collector): the collection + schedule paths consult expired() and drop via a new errExpiredSlot sentinel (Collect drops it silently). This also folds the old tracePruner cursor into one source of truth (Codex's suggested simplification).
  • Stale config silently ignored: startup now logs a deprecation warning when removed EXPORTER_MODE / EXPORTER_RETAIN_SLOTS env vars are set. (Verified live — the deploy template still sets EXPORTER_MODE=archive, now flagged and ignored.)
  • Honest retention semantics: EXPORTER_RETAIN_EPOCHS is documented as best-effort, forward-only (history aging out during downtime isn't reclaimed after restart).
  • Simplification: PruneSlot drops scheduled data with a single sd+slot prefix instead of iterating roles.
  • Tests: TestRetentionState now covers the floor/expired guard; new TestCollector_dropsExpiredSlot proves the hot path refuses expired slots; TestPruneSlot covers multiple validator roles. Stale comments fixed.

Assessed, not changed

  • Codex flagged the schedule worker as a second resurrection vector; it's effectively unreachable (the filler only enqueues current/current-1, far newer than the floor), but the cheap expired() guard was added there too as defense-in-depth.
  • True cross-restart disk bound (persisted prune cursor) deferred as a follow-up — over-building for a default-off feature; semantics now documented honestly.

Re-verified live (node 1002, commit 0319747): boots in full-tracing mode, correct handler set, deprecation warning fired, no panics; steady-state resource use matches the prior commit (~5.8 GB, == archive baseline). go build ./..., vet, gofmt, and the affected unit suites all pass.

- lint: use the promoted networkConfig.SlotsPerEpoch instead of the embedded
  networkConfig.Beacon.SlotsPerEpoch selector (staticcheck QF1008).
- extract runScheduleWorker's body into processScheduleJob so the retention
  guard is unit-testable.
- add tests covering the previously-uncovered new code: retention floor advance
  via Start, schedule-job skip for expired slots, the metrics-wrapper PruneSlot,
  PruneSlot DB-error aggregation, the deprecation warning, and an end-to-end
  Collect drop of an expired-slot message.

Patch coverage 70.8% -> 92.0%; golangci-lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@iurii-ssv

Copy link
Copy Markdown
Contributor

@greptile pls re-review

@iurii-ssv iurii-ssv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

return nil
}
if errors.Is(err, errExpiredSlot) {
return nil // message is for a slot outside the retention window; drop silently

@momosh-ssv momosh-ssv Jun 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Might be worth emitting a Debug log or a dropped_expired counter here rather than dropping silently.

As-is, a message dropped because its slot aged out is indistinguishable from a too-tight EXPORTER_RETAIN_EPOCHS quietly discarding live traffic — there's no signal to tell correct retention from a misconfiguration eating real data.

The same goes for the errExpiredSlot path in collectLateMessage.

Non-blocking btw.

@iurii-ssv iurii-ssv Jun 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good call on the observability — a dropped_expired counter (plus a Debug line in the late path, where the err = nil currently suppresses the deferred error log) is cheap.

One thing on the framing though: I don't think this can actually mask a too-tight window eating live traffic. Retention is opt-in and off by default (EXPORTER_RETAIN_EPOCHS=0floor stays 0, so expired() is always false and nothing is ever dropped), and when it's enabled the expired-drop only fires for messages older than the entire window. Live traces arrive within a few slots; to drop anything recent you'd have to set the window to ~1 epoch, which is an obviously-broken value rather than a subtle misconfig.

So I'd add it for debuggability of the retention feature itself — catching a floor-computation regression, or just confirming pruning behaves — rather than as a guard against silently discarding real data, since that's already prevented by the off-by-default + whole-window design.

Comment thread cli/operator/config.go
mode nodeMode
}

// isExporter reports whether the node runs as an exporter (standard or archive) rather than as an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
// isExporter reports whether the node runs as an exporter (always in former archive mode) rather than as an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Small nitpick for consistency, very optional.

@ovidiu-ssv-labs

Copy link
Copy Markdown
Contributor

Stage-hoodi deploy test — no regression detected ✅

Deployed PR branch (e0ded53e) on nodes 101-102 with EXPORTER=true,EXPORTER_MODE=archive, alongside a stage control on 103-104 (same env vars, without PR 2892/2895 fixes). Ran for ~20h.

VOLUNTARY_EXIT (live exit event, validator 838944)

  • 101-102 (PR branch): added voluntary exit task to pipeline — duty store populated, P2P messages accepted.
  • 103-104 (control): failed to schedule voluntary exit duty! + 4 partial signatures blackholed with too many duties per epoch, want <=0 — confirms pre-fix behavior reproduced on the control side.

Message validation drops
No no duty for this epoch or too many duties per epoch drops on any of the 4 nodes over the full 20h window. All observed ignores are benign exporter gossip noise (got decided message with too few signers), symmetric across both groups.

Duty scheduler
All 4 nodes started the scheduler and fetched ATTESTER + PROPOSER duties. No node emitted exporter standard mode: skipping duty scheduler.

Peer connectivity: 86-88 peers per node (consistent with fleet exporter baseline of ~87-94).

@momosh-ssv
momosh-ssv merged commit 651052b into fix/exporter-duty-store-blackhole Jun 23, 2026
7 checks passed
@momosh-ssv
momosh-ssv deleted the feat/exporter-remove-standard-add-retention branch June 23, 2026 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants