summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-13 19:41:44 +0300
committerPaul Buetow <paul@buetow.org>2026-05-13 19:41:44 +0300
commit0b454f367374e8cb97927627dacd0f1b216fe5ad (patch)
tree7e99ee96ee24ad31fe67e0fee0b4824f16425163 /internal
parent7b4f74ab11a2504d107372afebdfd77dec59ea42 (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>
Diffstat (limited to 'internal')
-rw-r--r--internal/ior.go31
-rw-r--r--internal/runtime/runtime.go16
-rw-r--r--internal/statsengine/engine.go16
-rw-r--r--internal/tui/tui.go20
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