diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-13 19:41:44 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-13 19:41:44 +0300 |
| commit | 0b454f367374e8cb97927627dacd0f1b216fe5ad (patch) | |
| tree | 7e99ee96ee24ad31fe67e0fee0b4824f16425163 | |
| parent | 7b4f74ab11a2504d107372afebdfd77dec59ea42 (diff) | |
introduce Accumulator interface in statsengine to separate ingestion from snapshot-building
Define statsengine.Accumulator (Ingest + Reset) to represent the
event-accumulation responsibility separately from runtime.SnapshotSource
(Snapshot), which handles the read side. This reduces the SRP violation in
Engine: callers that only push events now hold an Accumulator; callers that only
read statistics hold a SnapshotSource.
- Add Accumulator interface and compile-time assertion in statsengine/engine.go
- Add EventIngester type alias (= statsengine.Accumulator) in runtime/runtime.go
with a compile-time assertion, so callers in the runtime layer can reference
the ingestion contract without importing statsengine directly
- Split tuiRuntime.engine field into accumulator + snapSource so the event-loop
callback holds Accumulator and wireRuntimeBindings passes SnapshotSource to
SetDashboardSnapshotSource — making each consumer's dependency explicit
- Simplify resetDashboardSnapshotSource in tui.go to cast for interface{ Reset() }
independently of Snapshot(), removing the combined ad-hoc interface check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | internal/ior.go | 31 | ||||
| -rw-r--r-- | internal/runtime/runtime.go | 16 | ||||
| -rw-r--r-- | internal/statsengine/engine.go | 16 | ||||
| -rw-r--r-- | internal/tui/tui.go | 20 |
4 files changed, 64 insertions, 19 deletions
diff --git a/internal/ior.go b/internal/ior.go index 02a5b30..c8aa47b 100644 --- a/internal/ior.go +++ b/internal/ior.go @@ -163,8 +163,18 @@ func shouldRunTraceMode(cfg flags.Config) bool { // tuiRuntime holds all the per-restart state that the TUI trace starter // allocates and wires into the runtime bindings before each trace goroutine. +// +// The stats engine is split into two narrower interfaces to honour SRP: +// - accumulator accepts incoming event pairs (statsengine.Accumulator) +// - snapSource serves read-only snapshot queries (runtime.SnapshotSource) +// +// Both are satisfied by the same *statsengine.Engine instance, but holding +// them separately makes each consumer's dependency explicit and prevents +// callers from accidentally calling Ingest from snapshot-only paths or vice +// versa. type tuiRuntime struct { - engine *statsengine.Engine + accumulator statsengine.Accumulator + snapSource runtime.SnapshotSource streamBuf streamEventSink streamSrc runtime.StreamSource streamSeq *streamrow.Sequencer @@ -182,11 +192,14 @@ type tuiRuntime struct { func buildTUIRuntime(ctx context.Context, cfg flags.Config) (*tuiRuntime, error) { components := newRuntimeBuilder(cfg).Build() rt := &tuiRuntime{ - engine: components.engine, - streamBuf: components.streamBuf, - streamSrc: components.streamBuf, - streamSeq: components.streamSeq, - liveTrie: components.liveTrie, + // Wire the same engine instance into both roles: accumulator for + // event ingestion, snapSource for dashboard snapshot queries. + accumulator: components.engine, + snapSource: components.engine, + streamBuf: components.streamBuf, + streamSrc: components.streamBuf, + streamSeq: components.streamSeq, + liveTrie: components.liveTrie, } if bindings, ok := runtime.RuntimeBindingsFromContext(ctx); ok { @@ -215,7 +228,9 @@ func wireRuntimeBindings(rt *tuiRuntime, bindings runtime.TraceRuntimeBindings) } rt.recorder = bindings.Recorder() rt.filterEpoch = bindings.FilterEpoch() - bindings.SetDashboardSnapshotSource(rt.engine) + // Expose the snapshot-read side to the dashboard; the accumulator (write + // side) is used only by the event-loop callback below. + bindings.SetDashboardSnapshotSource(rt.snapSource) bindings.SetEventStreamSource(rt.streamSrc) bindings.SetLiveTrie(rt.liveTrie) return nil @@ -237,7 +252,7 @@ func makeTUIEventLoopConfigurer(ctx context.Context, cfg flags.Config, rt *tuiRu return } row := streamrow.New(rt.streamSeq.Next(), ep) - rt.engine.Ingest(ep) + rt.accumulator.Ingest(ep) rt.streamBuf.Push(row) if rt.recorder != nil { if err := rt.recorder.Record(row, rt.filterEpoch); err != nil { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index e8e278e..5a3c9a9 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -40,10 +40,20 @@ type EventSink interface { // behind this interface so the dashboard can retrieve live snapshots. // Snapshot returns (nil, nil) when the engine is nil. A non-nil error // indicates that snapshot construction failed and the result must be discarded. +// This is the read side of the stats engine; the write side is +// statsengine.Accumulator. type SnapshotSource interface { Snapshot() (*statsengine.Snapshot, error) } +// EventIngester is the write-only, event-feeding side of the stats engine, +// as needed by the trace event loop. It is an alias for the statsengine.Accumulator +// contract so callers in the runtime layer can reference a single type without +// importing statsengine directly. Callers that only push events should hold an +// EventIngester; callers that only read statistics should hold a SnapshotSource. +// *statsengine.Engine satisfies both interfaces. +type EventIngester = statsengine.Accumulator + // Snapshotter is the read-only subset of the trie contract used by consumers // that only need to poll the version and retrieve snapshot data. It mirrors the // Snapshotter interface in internal/tui/flamegraph but lives here so the core @@ -208,9 +218,11 @@ var ( // *probemanager.Manager must satisfy the probe-control surface exposed to the TUI. _ ProbeManager = (*probemanager.Manager)(nil) - // *statsengine.Engine must satisfy the snapshot-source contract used by the - // dashboard and the TUI runtime. + // *statsengine.Engine must satisfy both the snapshot-source contract (read + // side) and the event-ingestion contract (write side). These interfaces + // represent the two distinct responsibilities of the engine. _ SnapshotSource = (*statsengine.Engine)(nil) + _ EventIngester = (*statsengine.Engine)(nil) // *streamrow.RingBuffer must satisfy the full event-sink contract (read + // write sides), which is a superset of StreamSource. diff --git a/internal/statsengine/engine.go b/internal/statsengine/engine.go index f9602a9..b7d93fa 100644 --- a/internal/statsengine/engine.go +++ b/internal/statsengine/engine.go @@ -23,6 +23,22 @@ const ( DefaultTopN = 64 ) +// Accumulator is the event-ingestion side of the stats engine. +// It accepts incoming event pairs (Ingest) and supports resetting all +// accumulated state back to zero (Reset). Snapshot building is a separate +// responsibility expressed by runtime.SnapshotSource; separating the two +// satisfies SRP — callers that only push events hold an Accumulator, while +// callers that only read statistics hold a SnapshotSource. +type Accumulator interface { + // Ingest records one event pair into the in-memory aggregates. + Ingest(pair *event.Pair) + // Reset clears all accumulated stats and restarts series baselines. + Reset() +} + +// compile-time assertion: *Engine must satisfy Accumulator. +var _ Accumulator = (*Engine)(nil) + // Engine aggregates streaming syscall data into immutable snapshots. type Engine struct { mu sync.Mutex diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 695bd02..b73fbf8 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -226,22 +226,24 @@ func (r *runtimeBindings) advanceFilterEpoch() uint64 { } // resetDashboardSnapshotSource resets the dashboard snapshot source if it -// implements the resettable interface, then returns a fresh snapshot. Errors -// from Snapshot are silently dropped since callers handle a nil snapshot. +// implements the Resetter contract (i.e. exposes Reset()), then returns a +// fresh snapshot. The check is intentionally narrow — only Reset() is required +// so that test doubles and future sources can satisfy it without also +// implementing Ingest (which belongs to statsengine.Accumulator and is not +// needed here). Errors from Snapshot are silently dropped since callers handle +// a nil snapshot. func (r *runtimeBindings) resetDashboardSnapshotSource() *statsengine.Snapshot { src := r.dashboardSnapshotSource() if src == nil { return nil } - if resettable, ok := src.(interface { - Reset() - Snapshot() (*statsengine.Snapshot, error) - }); ok { + // statsengine.Accumulator satisfies this interface; any other source that + // exposes Reset() (e.g. test fakes) also qualifies. + if resettable, ok := src.(interface{ Reset() }); ok { resettable.Reset() - snap, _ := resettable.Snapshot() - return snap } - return nil + snap, _ := src.Snapshot() + return snap } // RuntimeBindingsFromContext returns the full TraceRuntimeBindings when the |
