Move metric declarations and bucket computation to the profile actor (ticket 104)

Metric declare/update/remove, backfill, and per-line apply_all_metrics
now run on the profile actor (engine::profile) instead of inline in the
primary's ingest closure, per idea 0001's resolved decision that the
primary handles params only. The profile actor reuses EngineSnapshot as
its own snapshot type -- declare_metric/update_metric/apply_all_metrics
work unchanged -- and mirrors the primary's param declarations into its
own snapshot on every wake (and synchronously before handling a
DeclareMetric/UpdateMetric message, since there's no ordering guarantee
between the watch-wake and message branches in tokio::select!) so
metric label/value resolution keeps working without storing a second
copy of raw log text; backfill reads lines through its cloned primary
IndexReader instead.

EngineHandle gained a profile_reader field so MetricDeclarations/
BucketValues/Promql queries route to the profile actor's snapshot
instead of the primary's -- without it, metrics could be declared but
never queried, since the primary no longer computes or stores them.
DeclareMetric/UpdateMetric/RemoveMetricDeclaration messages are still
sent to the primary's mpsc channel and proxied to the profile actor by
handle.rs's run() loop; EngineHandle's write-side API is otherwise
unchanged (ticket 106 gives API handlers a direct route). Alert
evaluation still reads the primary's (now metric-less) snapshot
directly -- an accepted gap until ticket 105 moves it, per idea 0001's
resolved decision that alerts move with metric computation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
This commit is contained in:
Hugh Kaznowski
2026-09-07 13:53:06 +01:00
co-authored by Claude Sonnet 5
parent 033e8bbab5
commit ce3e741cb0
6 changed files with 434 additions and 191 deletions
+69 -50
View File
@@ -61,7 +61,7 @@ impl IndexReadHandle {
}
pub struct IndexReader {
factory: left_right::ReadHandleFactory<EngineSnapshot>,
pub(crate) factory: left_right::ReadHandleFactory<EngineSnapshot>,
}
impl Clone for IndexReader {
@@ -81,6 +81,11 @@ impl IndexReader {
pub struct EngineHandle {
pub tx: mpsc::Sender<EngineMessage>,
pub reader: IndexReader,
/// Read-only view of the profile actor's snapshot (`super::profile`), which owns metric
/// declarations and bucket data (ticket 104) — `MetricDeclarations`/`BucketValues`/`Promql`
/// queries read through this instead of `reader`. Not part of the write path; the profile
/// actor's own `mpsc` channel (proxied through `EngineMessage`'s metric variants) is that.
pub(crate) profile_reader: IndexReader,
}
impl Clone for EngineHandle {
@@ -88,6 +93,7 @@ impl Clone for EngineHandle {
EngineHandle {
tx: self.tx.clone(),
reader: self.reader.clone(),
profile_reader: self.profile_reader.clone(),
}
}
}
@@ -106,13 +112,16 @@ impl EngineHandle {
}
}
/// Builds the handle/receiver/writer/version-receiver quadruple shared by `spawn` and, in
/// tests, by callers that want the raw receiver without an actor loop draining it.
/// Builds the primary actor's sender/receiver/reader/writer/version-receiver, shared by `spawn`
/// and, in tests, by callers that want the raw receiver without an actor loop draining it.
/// Stops short of building `EngineHandle` itself, since that also needs a profile-actor reader
/// that doesn't exist until the caller spawns (or fakes, see `test_handle`) the profile actor.
fn new_channel_and_handle(
channel_capacity: usize,
) -> (
EngineHandle,
mpsc::Sender<EngineMessage>,
mpsc::Receiver<EngineMessage>,
IndexReader,
IndexWriter,
watch::Receiver<u64>,
) {
@@ -123,8 +132,9 @@ fn new_channel_and_handle(
};
let (version_tx, version_rx) = watch::channel(0u64);
(
EngineHandle { tx, reader },
tx,
rx,
reader,
IndexWriter {
handle: write,
version: 0,
@@ -135,9 +145,14 @@ fn new_channel_and_handle(
}
pub fn spawn(settings: SettingsHandle, channel_capacity: usize) -> EngineHandle {
let (handle, rx, write, version_rx) = new_channel_and_handle(channel_capacity);
crate::engine::profile::spawn(handle.reader.clone(), version_rx);
tokio::spawn(run(rx, settings, write));
let (tx, rx, reader, write, version_rx) = new_channel_and_handle(channel_capacity);
let profile = crate::engine::profile::spawn(reader.clone(), version_rx);
let handle = EngineHandle {
tx,
reader,
profile_reader: profile.reader.clone(),
};
tokio::spawn(run(rx, settings, write, profile));
handle
}
@@ -145,7 +160,14 @@ pub fn spawn(settings: SettingsHandle, channel_capacity: usize) -> EngineHandle
pub(crate) fn test_handle(
channel_capacity: usize,
) -> (EngineHandle, mpsc::Receiver<EngineMessage>) {
let (handle, rx, _write, _version_rx) = new_channel_and_handle(channel_capacity);
let (tx, rx, reader, _write, _version_rx) = new_channel_and_handle(channel_capacity);
let handle = EngineHandle {
tx,
reader,
// No actor loop is spawned here at all, so nothing ever publishes -- an inert reader
// over an always-empty snapshot is all a query through this handle could see anyway.
profile_reader: crate::engine::profile::inert_reader(),
};
(handle, rx)
}
@@ -156,9 +178,14 @@ pub(crate) fn spawn_with_profile_observer(
settings: SettingsHandle,
channel_capacity: usize,
) -> (EngineHandle, tokio::sync::mpsc::UnboundedReceiver<u64>) {
let (handle, rx, write, version_rx) = new_channel_and_handle(channel_capacity);
let observed = crate::engine::profile::spawn_observed(handle.reader.clone(), version_rx);
tokio::spawn(run(rx, settings, write));
let (tx, rx, reader, write, version_rx) = new_channel_and_handle(channel_capacity);
let (profile, observed) = crate::engine::profile::spawn_observed(reader.clone(), version_rx);
let handle = EngineHandle {
tx,
reader,
profile_reader: profile.reader.clone(),
};
tokio::spawn(run(rx, settings, write, profile));
(handle, observed)
}
@@ -166,6 +193,7 @@ async fn run(
mut rx: mpsc::Receiver<EngineMessage>,
settings: SettingsHandle,
mut write: IndexWriter,
profile: crate::engine::profile::ProfileHandle,
) {
let mut max_logs: Option<usize> = None;
let mut metric_sample_sequence: u64 = 0;
@@ -209,10 +237,10 @@ async fn run(
let text_for_op = Arc::clone(&text);
write.mutate(move |snap: &mut EngineSnapshot| {
// Two independently-callable stages (ticket 101): primary/param
// extraction, then profile-scoped metric computation.
// Primary/param extraction only (ticket 101) -- metric computation
// (ticket 104) now happens on the profile actor, which wakes on this
// publish and reads the newly-visible line itself.
snap.ingest_params(sequence, timestamp, &text_for_op, row_header.as_ref());
snap.apply_all_metrics(sequence, timestamp, &text_for_op);
snap.update_sort_helper_indexes_on_ingest(sequence, timestamp, &text_for_op);
snap.purge_stale_sort_helper_indexes();
snap.evict_excess_logs(max_logs);
@@ -327,49 +355,40 @@ async fn run(
request,
respond_to,
} => {
let result = write
.peek()
.map(|guard| guard.validate_metric_request(&request).map(|_| ()))
.unwrap_or_else(|| Err("engine is shutting down".to_string()));
let succeeded = result.is_ok();
let request_for_op = request.clone();
write.mutate(move |snap: &mut EngineSnapshot| {
if succeeded {
let _ = snap.declare_metric(request_for_op.clone());
}
});
let _ = respond_to.send(result);
// Metric declarations live on the profile actor (ticket 104), not here --
// proxy the message rather than mutating the primary's snapshot.
let _ = profile
.tx
.send(crate::engine::profile::ProfileMessage::DeclareMetric {
request,
respond_to,
})
.await;
}
EngineMessage::UpdateMetric {
name,
request,
respond_to,
} => {
let result = write
.peek()
.map(|guard| guard.validate_update_metric(&name, &request).map(|_| ()))
.unwrap_or_else(|| Err("engine is shutting down".to_string()));
let succeeded = result.is_ok();
let existing_name = name.clone();
let request_for_op = request.clone();
write.mutate(move |snap: &mut EngineSnapshot| {
if succeeded {
let _ = snap.update_metric(&existing_name, request_for_op.clone());
}
});
let _ = respond_to.send(result);
let _ = profile
.tx
.send(crate::engine::profile::ProfileMessage::UpdateMetric {
name,
request,
respond_to,
})
.await;
}
EngineMessage::RemoveMetricDeclaration { name, respond_to } => {
let existed = write.peek().is_some_and(|guard| {
guard
.index
.contains_key(&IndexKey::MetricDeclaration { name: name.clone() })
});
let name_for_op = name.clone();
write.mutate(move |snap: &mut EngineSnapshot| {
snap.remove_metric_declaration(&name_for_op);
});
let _ = respond_to.send(existed);
let _ = profile
.tx
.send(
crate::engine::profile::ProfileMessage::RemoveMetricDeclaration {
name,
respond_to,
},
)
.await;
}
EngineMessage::EnsureSortHelperIndex {
param_name,