18 KiB
Idea: split the engine's single actor into a log-ingestion worker + per-profile worker(s)
Original note
This needs to be fleshed out with ai.
The general idea is that the primary engine ingesting worker is doing too much. It should only parse logs to extract params.
Since profiles carry additional (non-derived) params, metrics, dashboards, and panels - those should be stored separately and can be handled by a different worker. The worker would have read access to the primary log sequence and derived params index, but would construct it's own profile perspective. It has write access and then
The profiles need to be informed of new messages to index.
What if message needs to be different format? Thats per-profile.
System param suppressions?
Regex indexing? We probably want text indexing on the primary while also
Values in the primary index should be arcs, to prevent cloning allocations.
Resolved decisions (2026-09-07)
Clarified interactively; supersedes the open questions raised below where they conflict.
- No profiling gate. Proceeding on the code-reading evidence already in
this doc (double/triple string clones per line, full-
EngineSnapshotdeep-clone on everyleft_rightreconciliation) rather than waiting on asamplyprofile first. - Three actors, not two. The split is: reader actor (tails
stdin/file, splits into lines, timestamps, wraps each line in an
Arc, sends downstream) → primary actor (ingests timestampedArclines, extracts declared + auto params only, maintains theparam=valueindex — no metrics, no profile logic) → profile actor (holds its ownleft_rightreader over the primary's index; builds metrics, custom params, timeseries, and PromQL evaluation for the one active profile). This resolves the "per-profile message format" question below: there is no per-profile parsing fork on the primary — format/shape parsing of an already-classified line is entirely the profile actor's concern, on top of the shared raw text. - Still exactly one active profile at a time. No concurrent multi-profile indexing; the split changes how the one active profile's data is computed relative to the primary, not the user-facing single-active-profile model.
- Alerts move to the profile actor. Alert rules are profile-scoped and read metric buckets, so they move with metric computation rather than staying on the primary.
- Primary→profile signaling: the primary actor notifies subscribers
(e.g. a
tokio::sync::watchcarrying the latest processed position/index) right after eachleft_rightsync completes. The profile actor wakes on that signal and re-reads whatever's newly visible through its ownleft_rightreader — no busy-polling, no second copy of the data in transit. - Eviction wins over profile-actor lag. If the profile actor is
behind (e.g. mid-backfill on a newly declared metric) when the primary
evicts old lines (
SetMaxLogs), the primary evicts on schedule regardless — the profile actor silently misses data for the evicted range. Simplest option, matches today's single-actor retention model. - System param suppressions: dropped from scope. Not needed right now; revisit as a separate idea if it becomes a real requirement.
- Migration path: incremental, in this order:
- Arc-ify stored values (
Arc<str>/Arc<String>for log text and metric-captured strings) — independent win, and the load-bearing precondition for cheap cross-actor reads in steps 2–3. - Extract metric computation into a separately-callable stage, still within the single actor.
- Split into the reader/primary/profile actors described above.
- Arc-ify stored values (
Current architecture (confirmed in code)
- One process, one actor, today.
spawn_engine(src/cli/mod.rs:95) callsengine::spawn(...)exactly once per server run.load_active_profile(src/cli/mod.rs:105-124) loads one active profile's param/metric declarations into that single engine at startup, before ingest starts, with an explicit comment explaining why: "there's no way to reprocess earlier lines later, so this must happen beforespawn_ingest" (src/cli/mod.rs:104-106). All otherengine::spawncall sites (src/api/mod.rs:853,1459, several insrc/config/profile.rs) are test harnesses, not a second production engine instance. - The actor loop is
run(),src/engine/handle.rs:130-365— a singlewhile let Some(message) = rx.recv().awaitover onempsc::Receiver<EngineMessage>(:141). Every mutation (Ingest,IngestMetricSample,DeclareParam/RemoveParamDeclaration,ClearParams,DeclareMetric/UpdateMetric/RemoveMetricDeclaration,SetMaxLogs,EnsureSortHelperIndex,Barrier— full listsrc/engine/message.rs:30-63) funnels through this one channel and one task. - Queries already bypass this actor.
EngineQuery(src/engine/message.rs:12-27) doesn't go through the mpsc channel —EngineHandle::askreads directly off aleft_rightIndexReader/ReadHandle(src/engine/handle.rs:49-73, entry pointsrc/api/mod.rs:218-219, evaluated byhandle_query_ro,src/engine/snapshot/mod.rs:856-858). So read-side scaling is already decoupled from the actor loop today — this idea is entirely about the write/mutation path. - Per-line ingest (
EngineMessage::Ingestarm,src/engine/handle.rs:143-219) does, synchronously, in oneleft_rightwrite op, per line: ingest-lag/watermark bookkeeping, log-stream header detection, storing the line asDataValue::LogLiteral, auto-param detection (auto_declare_params), running every declared metric's regex against the line (apply_all_metrics→apply_metric_to_log/resolve_param_value,src/engine/snapshot/mod.rs:409-460,821-851), sort-helper-index maintenance, log eviction, and then — after publish —alerts::evaluate_alertsagainst the resulting snapshot (handle.rs:199-219), which reads metric buckets that were just computed in the same message. - Module size:
src/engine/is ~5.5k lines excluding tests (snapshot/ mod.rsalone is 1100 lines — the wholeEngineSnapshotimpl per its own header comment). - No
Arc, everywhere owned data. The index is one flatBTreeMap<IndexKey, DataValue>(snapshot/mod.rs:43) of ownedStrings (DataValue::LogLiteral { text: String },src/engine/data_value.rs:81). Per ingested line, the raw text is cloned at least twice for storage alone (handle.rs:175,184-186), and every metric that matches the line clones its own copy of the label/value strings it captures (apply_metric_to_log,mod.rs:426,439,444) — with N metrics referencing overlapping params, one line produces N independent owned-String allocations of essentially the same substrings. left_right's reconciliation deep-clones everything. After a batch of writes,left_right::Absorb::sync_withclones the entireEngineSnapshot(*self = first.clone(),handle.rs:29-31) to reconcile the read-side copy — i.e. the "values should be Arc" note isn't just about avoiding per-line clones, it's also about avoiding a full-BTreeMap-of-Strings deep clone on every reconciliation.- No text/regex index exists.
CompiledFilter::Regex(re) => re.is_match(text)(snapshot/filter.rs:159) runs per line at query time;IndexKey(src/engine/index_key.rs:8-32) has no inverted/full-text structure. Every filter-bar query re-scans and re-matches the whole retained range. - Backfill is a full synchronous history replay today, in the same
actor.
declare_metric/update_metric(snapshot/mod.rs:330-391) →backfill_metric(:481-509) collects all retained lines into aVecand replaysapply_metric_to_logover every one, inside the single actor's message handling — blocking ingest/alerts/other declarations for the duration.load_into_engine(src/config/profile.rs:332-384) does this once per metric, awaiting each response, so loading a profile with N metrics against L retained lines costs O(N·L) synchronous scan time. This is also the mechanism 0004-profile-switch-reindexing-cost describes for runtime profile switching via theactivate_profileAPI handler (src/api/mod.rs:712-768, which removes then re-declares via the same messages) — worth reconciling that this research pass separately characterized profile activation at startup as one-shot ("no way to reprocess earlier lines later"), while the earlier research for ticket 099/idea 0004 confirmed a runtime reload path exists viaactivate_profile. Both are true: startup load is one-shot by necessity (ingest hasn't started yet), and the runtime reload path reuses the same declare/backfill machinery after the fact — but this is worth double-checking before design work starts, since the two research passes framed it differently. - Alerts are coupled to the single actor's local state. Alert
evaluation runs directly in the ingest arm against the just-mutated
snapshot (
handle.rs:199-211,alerts::evaluate_alerts,src/engine/alerts.rs:211), readingalert_states: HashMap<String, AlertRuntimeState>that lives on the single actor task's own stack frame (handle.rs:137) — there's no mechanism today for one actor to read another actor's in-memory state. - Existing multi-task precedent is producer-side only.
src/engine/ingest.rs's stdin/file-tailing and Prometheus-polling tasks each hold a clonedEngineHandleand push into the same actor's mpsc channel (spawned atsrc/cli/mod.rs:136-145) — a plausible pattern for "many producers, one consumer," but there's no existing precedent for multiple consuming actors each owning their ownleft_rightwrite side that need to stay coherent with each other or with a shared upstream.
Implications and things to consider
Performance
- The stated goal — "the primary worker should only parse logs to
extract params" — would remove per-line metric-regex evaluation
(
apply_all_metrics) from the primary's critical path, which is real work today (N metrics × regex match per line). That's a genuine win if profile workers can consume the primary's output without re-paying an equivalent cost themselves — but since a profile worker still has to run its own metrics' regexes against each line to build its own buckets, the total CPU work done across the process doesn't shrink; what changes is that it's no longer all serialized on one task, so it can run concurrently with the primary (a real win on a multi-core box) and a slow/backfilling profile worker no longer blocks raw ingestion. - The "values in the primary index should be Arcs" change is
independently valuable regardless of whether the actor split
happens — it directly addresses the confirmed double/triple string
cloning per line (storage clone + per-metric label/value clones) and
the full-snapshot deep-clone on every
left_rightreconciliation. Worth sequencing as a prerequisite: switching toArc<str>(orArc<String>) for stored text means a profile worker reading "the primary log sequence" can cheaply hold references to the same underlying bytes instead of needing its own copies — this is likely a load-bearing precondition for the split being worth it at all, not a nice-to-have alongside it. - A profile worker needing "read access to the primary log sequence
and derived params index" implies either (a) a second
left_rightreader over the primary's structures (cheap, if values are Arc'd — see above) or (b) a message-replay feed (the primary notifies profile workers of new lines, per the note's "profiles need to be informed of new messages to index"). Option (b) reintroduces a second data copy per profile worker unless the message itself only carries anArc/index reference rather than owned data — otherwise this recreates the same N-copies-per-line problem this idea is trying to remove, just distributed across processes/tasks instead of within one. - Backfill cost (O(N metrics × L retained lines), see 0004-profile-switch-reindexing-cost) doesn't disappear with a split — it moves to whichever worker owns the profile. The upside is it no longer blocks the primary's ingest/query-serving while it happens, which is the main perf argument for doing this at all, per that idea's "Performance" section.
- Full-text/regex indexing on the primary (per the note's "Regex indexing?") is a materially different performance investment from the Arc/split work — an inverted or trigram index trades ingest-time CPU and memory for query-time speed on filter-bar searches, and is worth scoping as its own effort (see 0005-additional-read-indexes) rather than folded into this split, since it doesn't depend on the actor topology.
Code structure
- This is the single largest structural change on the idea list —
src/engine/(~5.5k lines) currently assumes one writer owning oneleft_rightpair. Splitting introduces: a defined "primary perspective" (raw logs + derived/auto params only, no profile-specific metrics) as its own bounded module, a "profile perspective" abstraction for the one active profile, a notification/subscription mechanism from primary to the profile actor (the note's "profiles need to be informed of new messages to index" — doesn't exist in any form today; the closest analogue,ingest.rs's producer tasks feeding one consumer, runs the opposite direction). Resolved (see "Resolved decisions"): the primary signals via e.g. atokio::sync::watchafter eachleft_rightsync completes, and the profile actor re-reads through its ownleft_rightreader rather than the primary pushing data. A query-fan-out/merge layer is still needed for any request that needs both raw logs and profile-derived data in one response (todayLogs/Timelinequeries read both from a singleEngineSnapshotin one pass, e.g.query.rs:154'slog_entry_paramscall againstcompiled_paramsfrom the same snapshot — splitting the data sources means this becomes a real join across two snapshots/workers instead of one borrow). - Alert evaluation (
handle.rs:199-211) currently depends on running immediately after metric bucketing in the same message, against the same snapshot. Resolved (see "Resolved decisions"): alerts move with metric computation into the profile actor, since alert rules are profile-scoped and read the metric buckets computed there. EngineHandle's current API (handle.rs:49-73for reads, the mpscEngineMessagesend for writes) would need to become two APIs, or one handle type parameterized by "primary" vs. "profile" — worth deciding whetherapi.rs's handlers (which currently hold oneEngineHandleinApiState) address a single combined handle that internally fans out, or explicitly juggle a primary handle plus an active-profile handle.- Given
CLAUDE.md's existing note thatengine.rsis "the file to read first" and already the largest/highest-churn file in the repo (13 commits/90d per the repowise summary), this split is also a legibility opportunity, not just a performance one — today a reader has to understand ingest, metrics, alerts, and backfill as one intertwined concern to understand any one of them.
Functionality
- Per-profile message format — resolved, see "Resolved decisions"
above: there's no per-profile parsing fork on the primary. Log-stream
header detection and auto-param extraction stay a one-time, primary-side,
profile-agnostic concern (
derive_log_stream_params_from_row,auto_declare_params); any further message-shape interpretation the profile actor needs happens independently, on top of the shared rawArctext, in the profile actor itself. - System param suppressions — dropped from scope (see "Resolved decisions"). Revisit as a separate idea if it becomes a real need.
- Concurrency — resolved: still strictly one active profile at a time. The split changes how that one profile's data is computed relative to the primary, not the user-facing model — no multi-profile concurrent indexing.
Not yet considered
- Failure/restart semantics — partially resolved: the profile actor falling behind the primary during a backfill is expected and accepted (see "Resolved decisions" — eviction wins over lag, silent gaps for the evicted range). Still open: what happens if the profile actor crashes outright (not just lags) — does it restart and replay from whatever the primary currently retains, or is there a persisted checkpoint? Not yet decided; likely fine to punt until the split is actually implemented, since "restart and rebuild from currently-retained history" is a reasonable default consistent with the accepted-gaps decision above.
- Retention/eviction coordination — resolved, see "Resolved
decisions": no low-water-mark. The primary evicts on its own schedule
(
evict_excess_logs,handle.rs:195,SetMaxLogs) regardless of the profile actor's progress; a lagging profile actor silently misses data for the evicted range rather than blocking primary eviction. - Testing story:
src/engine/tests.rs(2348 lines) and thetest_handlepattern (see ticket 099) are built entirely around one actor with one channel — a split roughly doubles the kinds of integration tests needed (primary alone, profile worker alone, primary+profile interaction) and the existing test harness would need real rework, not just extension. Still open — no resolution yet. - Migration path — resolved, see "Resolved decisions": incremental, in three steps (Arc-ify stored values → extract metric computation into a still-single-actor stage → split into reader/primary/profile actors).
- Whether this is worth it at all without profiling evidence —
resolved, see "Resolved decisions": proceeding without a
samplyprofile, on the code-reading evidence already documented in this file.