feat(telemetry): add sequenced runtime dashboard signals

Rate-limit actor census and activity telemetry while preserving immediate lifecycle transitions. Route managed-process output into Fleet node tails, bound explorer retention, aggregate SWIM probes, and make sampler health transition-based. Restore stable explorer synchronization and refine Fleet bulk controls.
This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-08-24 01:28:38 +04:00
parent 0411c20b94
commit b95163823e
32 changed files with 2017 additions and 388 deletions

1
Cargo.lock generated
View file

@ -4368,6 +4368,7 @@ dependencies = [
"swactor-process",
"swactor-transport",
"tar",
"telemetry",
"tempfile",
"tokio",
]

View file

@ -58,7 +58,7 @@ use parking_lot::Mutex;
use serde_json::{Value, json};
use swactor::actor::{ActorAddress, ActorInterface};
use swactor::runtime::{Ctx, ExternalSender, Inbox, Runtime};
use swactor::stats::{ActorSnapshot, StatsHook};
use swactor::stats::{ActorSnapshot, StatsHook, StatsSnapshotKind};
use swactor_engine::{ActorCompletion, Engine, EngineHandle, TokioBackend, TokioConfig};
use swactor_job_runner::{NodeJobActor, register_job_codecs};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
@ -209,7 +209,7 @@ struct InspectableStatsHook {
}
impl StatsHook for InspectableStatsHook {
fn on_tick(&self, worker_id: usize, snapshots: &[ActorSnapshot]) {
fn on_snapshot(&self, worker_id: usize, snapshots: &[ActorSnapshot], kind: StatsSnapshotKind) {
self.inspector.latest.lock().insert(
worker_id,
snapshots
@ -222,7 +222,7 @@ impl StatsHook for InspectableStatsHook {
})
.collect(),
);
self.inner.on_tick(worker_id, snapshots);
self.inner.on_snapshot(worker_id, snapshots, kind);
}
}
@ -599,6 +599,13 @@ impl SamplerHealthContext {
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SamplerHealthState {
Waiting,
Ready,
Failed,
}
fn sampler_health_payload(
context: SamplerHealthContext,
sampler: &str,
@ -681,18 +688,33 @@ fn submit_sampler_sample_health(
producer: &TelemetryProducer,
health_channel: ChannelId,
context: SamplerHealthContext,
sampler: &str,
sample_channel: &str,
identity: (&str, &str),
seq: u64,
error: Option<&str>,
state: &Mutex<SamplerHealthState>,
) {
let (status, detail) = match error {
Some(error) => (
let (sampler, sample_channel) = identity;
let mut state = state.lock();
let (status, detail, next) = match (*state, error) {
(SamplerHealthState::Waiting, None) => (
"ready",
json!({"state":"sample_observed","sample_seq":seq}),
SamplerHealthState::Ready,
),
(SamplerHealthState::Failed, None) => (
"recovered",
json!({"state":"sample_observed","sample_seq":seq}),
SamplerHealthState::Ready,
),
(SamplerHealthState::Waiting | SamplerHealthState::Ready, Some(error)) => (
"failed",
json!({"state":"error","sample_seq":seq,"error":error}),
SamplerHealthState::Failed,
),
None => ("ready", json!({"state":"sample_observed","sample_seq":seq})),
(SamplerHealthState::Ready, None) | (SamplerHealthState::Failed, Some(_)) => return,
};
*state = next;
drop(state);
submit_sampler_health(
producer,
health_channel,
@ -738,6 +760,7 @@ fn spawn_blocking_sampler_task<State, Sample>(
error_of,
} = config;
let started_producer = producer.clone();
let health_state = Arc::new(Mutex::new(SamplerHealthState::Waiting));
telemetry::hardware::spawn_blocking_sampler(
engine,
interval,
@ -758,10 +781,10 @@ fn spawn_blocking_sampler_task<State, Sample>(
&producer,
health_channel,
health_context,
sampler,
sample_channel,
(sampler, sample_channel),
seq,
error_of(&sample),
&health_state,
);
producer.submit_record(channel, &sample);
},
@ -2001,6 +2024,7 @@ fn run() -> Result<(), String> {
.spawn(
NodeJobActor::unbound(workdir, stack.runtime.create_sender())
.with_actor_timers(engine.handle())
.with_process_telemetry(telemetry.producer.clone())
.with_route_registrar(job_route_registrar)
.with_data_plane(Arc::new(data_plane.clone())),
)
@ -2769,6 +2793,12 @@ fn emit_swim_telemetry(
.producer
.submit_record(telemetry.channels.swim_probes, &record);
}
if let Some(summary) = stack.drain_swim_probe_summary() {
let record = stack.swim_probe_summary_record(summary, local_phase);
telemetry
.producer
.submit_record(telemetry.channels.swim_probes, &record);
}
}
#[derive(Clone, Copy)]
@ -5378,6 +5408,104 @@ mod control_flow_properties {
const DRIVE_PER_ACTION: usize = 16;
const FINAL_DRIVE_BUDGET: usize = 256;
#[test]
fn sampler_health_emits_only_state_transitions() {
let endpoint = TelemetryEndpoint::with_descriptor(
StreamDescriptor {
stream: StreamId::new(NodeId::new("sampler-health-test"), Lifetime(1)),
label: None,
origin: StreamOrigin::RemoteNode,
},
16,
16,
);
let producer = endpoint.producer();
let health_channel = producer.register_channel(
NODE_SAMPLER_CHANNEL,
ChannelContent::JsonRecord {
schema: Some(NODE_SAMPLER_CHANNEL.to_owned()),
},
);
let subscription = endpoint.subscribe_all("sampler-health-transitions");
let context = SamplerHealthContext {
run_id: 1,
node_id: 2,
stage_index: 0,
};
let state = Mutex::new(SamplerHealthState::Waiting);
submit_sampler_started(
&producer,
health_channel,
context,
"cpu",
"host.cpu",
Duration::from_secs(1),
);
submit_sampler_sample_health(
&producer,
health_channel,
context,
("cpu", "host.cpu"),
0,
None,
&state,
);
submit_sampler_sample_health(
&producer,
health_channel,
context,
("cpu", "host.cpu"),
1,
None,
&state,
);
submit_sampler_sample_health(
&producer,
health_channel,
context,
("cpu", "host.cpu"),
2,
Some("unavailable"),
&state,
);
submit_sampler_sample_health(
&producer,
health_channel,
context,
("cpu", "host.cpu"),
3,
Some("still unavailable"),
&state,
);
submit_sampler_sample_health(
&producer,
health_channel,
context,
("cpu", "host.cpu"),
4,
None,
&state,
);
endpoint.tick();
let statuses = subscription
.drain_available()
.into_iter()
.filter_map(|event| match event {
TelemetryEvent::Frame(delivery) if delivery.channel.channel == health_channel => {
serde_json::from_slice::<Value>(&delivery.payload)
.ok()
.and_then(|value| value["status"].as_str().map(str::to_owned))
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
statuses,
["started", "waiting", "ready", "failed", "recovered"]
);
}
#[test]
fn hardware_sampler_runs_as_engine_task_not_actor() {

View file

@ -2354,6 +2354,7 @@ fn serve_cluster_flush_reply(reply: ManualControlReply) -> Option<ServeClusterMs
ManualControlReply::Accepted(_)
| ManualControlReply::Provider(_)
| ManualControlReply::Status(_)
| ManualControlReply::FleetStatus(_)
| ManualControlReply::Offers(_)
| ManualControlReply::Rejoined(_) => None,
}
@ -2788,6 +2789,10 @@ fn emit_swim_probe_events(
let record = stack.swim_probe_event_record(event, local_phase);
orch_telemetry.emit_record(dashboard, &record);
}
if let Some(summary) = stack.drain_swim_probe_summary() {
let record = stack.swim_probe_summary_record(summary, local_phase);
orch_telemetry.emit_record(dashboard, &record);
}
}
pub(crate) fn env_optional(name: &str) -> Option<String> {

View file

@ -429,6 +429,7 @@ pub(crate) fn plugin(
let routes = Router::new()
.route(FLEET_CONTROL_SCRIPT_URL, get(fleet_control_script))
.route("/api/control/status", get(status))
.route("/api/control/fleet", get(fleet_status))
.route("/api/control/actors", get(actor_stats))
.route("/api/control/provision", post(provision))
.route("/api/control/kill", post(kill))
@ -645,6 +646,37 @@ async fn status(State(state): State<ControlHttpState>) -> Response {
.await
}
async fn fleet_status(State(state): State<ControlHttpState>) -> Response {
let response_rx = match begin_request_reply(&state, CONTROL_REPLY_TIMEOUT, |reply_to| {
ManualControlMsg::QueryFleet { reply_to }
}) {
Ok(response_rx) => response_rx,
Err(response) => return *response,
};
match response_rx.await {
Ok(ManualControlReply::FleetStatus(model)) => {
Json(ManualControlReply::FleetStatus(model)).into_response()
}
Ok(ManualControlReply::Rejected(error)) => {
(StatusCode::CONFLICT, Json(ErrorResponse { error })).into_response()
}
Ok(_) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "manual control returned an unexpected Fleet status reply".to_owned(),
}),
)
.into_response(),
Err(error) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: format!("control reply observer stopped: {error}"),
}),
)
.into_response(),
}
}
async fn actor_stats(State(state): State<ControlHttpState>) -> impl IntoResponse {
Json(state.runtime.stats())
}

View file

@ -29,9 +29,10 @@ use distribution::registry_actor::{RegistryActor, RegistryIn, RegistryView};
use distribution::swim::actor::{MembershipChanged, SwimActor, SwimIn};
use distribution::swim::member_list::MemberList;
use distribution::swim::probe::SwimConfig;
use distribution::swim::telemetry::{ObservedProbeEvent, ObservedTransition, SwimTelemetry};
use distribution::telemetry::MembershipTransition;
use distribution::telemetry::SwimProbeEvent;
use distribution::swim::telemetry::{
ObservedProbeEvent, ObservedProbeSummary, ObservedTransition, SwimTelemetry,
};
use distribution::telemetry::{MembershipTransition, SwimProbeEvent, SwimProbeSummary};
use distribution::transport_bridge::{
Outbox, OutboxPeerDirectory, OutboxRouteBinder, RelayMirror, RouteView, RouteViewTransport,
};
@ -314,6 +315,10 @@ impl DistributionRuntimeStack {
self.swim_telemetry.drain_probe_events()
}
pub(crate) fn drain_swim_probe_summary(&self) -> Option<ObservedProbeSummary> {
self.swim_telemetry.drain_probe_summary()
}
pub(crate) fn swim_recent_probe_targets(&self) -> Vec<String> {
self.swim_telemetry
.recent_targets()
@ -353,6 +358,28 @@ impl DistributionRuntimeStack {
lifeguard_enabled: config.lifeguard.is_some(),
}
}
pub(crate) fn swim_probe_summary_record(
&self,
summary: ObservedProbeSummary,
local_phase: &str,
) -> SwimProbeSummary {
SwimProbeSummary {
event: "summary".to_owned(),
interval_ms: summary.interval_ms,
sent: summary.sent,
acked: summary.acked,
direct_sent: summary.direct_sent,
indirect_sent: summary.indirect_sent,
rtt_samples: summary.rtt_samples,
rtt_ms_min: summary.rtt_ms_min,
rtt_ms_p50: summary.rtt_ms_p50,
rtt_ms_p95: summary.rtt_ms_p95,
rtt_ms_max: summary.rtt_ms_max,
local_phase: local_phase.to_owned(),
probe_interval_ms: duration_ms_u64(self.swim_config.probe_interval),
}
}
pub(crate) fn membership_transition(
&self,
transition: &ObservedTransition,

View file

@ -1,42 +1,61 @@
(() => {
const CONTROL_ID = 'myelin-fleet-control';
const NODE_CONTROL_ID = 'myelin-fleet-control';
const BULK_CONTROL_ID = 'myelin-fleet-bulk-control';
const CONFIRM_ID = 'myelin-confirm-dialog';
const CONFIRM_STYLE_ID = 'myelin-confirm-dialog-style';
const STYLE_ID = 'myelin-fleet-control-style';
const selectedJobs = new Map();
const selectedNodes = new Set();
let lastModel = null;
let syncing = false;
const page = document.getElementById('page');
if (!page) return;
function confirmKill(logicalNodeId) {
function installUi() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
.myelin-control { display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin:0 0 12px }
.myelin-control button { border:1px solid var(--cyan);background:transparent;color:var(--cyan);border-radius:var(--r);padding:6px 10px;font:600 12px var(--mono);cursor:pointer }
.myelin-control button.danger { border-color:var(--bad);color:var(--bad) }
.myelin-control button:disabled { cursor:not-allowed;opacity:.55 }
.myelin-control-message { color:var(--muted);font:12px var(--mono) }
.myelin-bulk-control { width:fit-content;margin:0 0 12px auto;padding:6px 8px;border:1px solid var(--divider);background:transparent;border-radius:var(--r) }
.node-card[data-bulk-selected="true"] { border-color:var(--bad);background:var(--selected);box-shadow:inset 3px 0 0 var(--bad) }
.node-card[data-bulk-killable="true"] { user-select:none }
.myelin-confirm { width:min(440px,calc(100vw - 32px));padding:0;color:var(--text);background:var(--panel);border:1px solid var(--bad);border-radius:var(--r);box-shadow:0 18px 60px rgba(0,0,0,.55) }
.myelin-confirm::backdrop { background:rgba(0,6,12,.78) }
.myelin-confirm form { display:grid;gap:14px;padding:18px }
.myelin-confirm h2,.myelin-confirm p { margin:0 }
.myelin-confirm h2 { color:var(--bad) }
.myelin-confirm-actions { display:flex;justify-content:flex-end;gap:8px }
.myelin-confirm button { padding:6px 12px;background:transparent;color:var(--text);border:1px solid var(--border);border-radius:var(--r);cursor:pointer;font:600 13px var(--mono) }
.myelin-confirm button[value="confirm"] { color:var(--danger-ink);background:var(--danger-fill);border-color:var(--danger-border) }
`;
document.head.append(style);
}
function confirmTermination(title, message, action) {
let dialog = document.getElementById(CONFIRM_ID);
if (!dialog) {
const style = document.createElement('style');
style.id = CONFIRM_STYLE_ID;
style.textContent = `
.myelin-confirm { width:min(440px,calc(100vw - 32px));padding:0;color:var(--text);background:var(--panel);border:1px solid var(--bad);border-radius:var(--r);box-shadow:0 18px 60px rgba(0,0,0,.55) }
.myelin-confirm::backdrop { background:rgba(0,6,12,.78) }
.myelin-confirm form { display:grid;gap:14px;padding:18px }
.myelin-confirm h2,.myelin-confirm p { margin:0 }
.myelin-confirm h2 { color:var(--bad) }
.myelin-confirm-actions { display:flex;justify-content:flex-end;gap:8px }
.myelin-confirm button { padding:6px 12px;background:transparent;color:var(--text);border:1px solid var(--border);border-radius:var(--r);cursor:pointer;font:600 13px var(--mono) }
.myelin-confirm button[value="confirm"] { color:var(--danger-ink);background:var(--danger-fill);border-color:var(--danger-border) }
`;
document.head.append(style);
dialog = document.createElement('dialog');
dialog.id = CONFIRM_ID;
dialog.className = 'myelin-confirm';
dialog.setAttribute('aria-labelledby', 'myelin-confirm-title');
dialog.setAttribute('aria-describedby', 'myelin-confirm-message');
dialog.innerHTML = `<form method="dialog">
<h2 id="myelin-confirm-title">Terminate managed node?</h2>
<h2 id="myelin-confirm-title"></h2>
<p id="myelin-confirm-message"></p>
<div class="myelin-confirm-actions">
<button value="cancel" autofocus>Cancel</button>
<button value="confirm">Terminate node</button>
<button value="confirm"></button>
</div>
</form>`;
document.body.append(dialog);
}
dialog.querySelector('#myelin-confirm-message').textContent =
`Terminate managed node ${logicalNodeId}? Vast.ai contracts are destroyed and billing stops.`;
dialog.querySelector('#myelin-confirm-title').textContent = title;
dialog.querySelector('#myelin-confirm-message').textContent = message;
dialog.querySelector('[value="confirm"]').textContent = action;
dialog.returnValue = 'cancel';
return new Promise(resolve => {
dialog.addEventListener('close', () => resolve(dialog.returnValue === 'confirm'), { once: true });
@ -44,28 +63,152 @@
});
}
async function syncControl() {
let model;
try {
const response = await fetch('/api/control/status', { cache: 'no-store' });
if (!response.ok) return;
const payload = await response.json();
model = payload.Status;
} catch (_) {
function killable(node) {
return node && !['kill_requested', 'stopping', 'stopped', 'orphan'].includes(node.phase);
}
function managedNodes(model) {
return Array.isArray(model?.nodes) ? model.nodes : [];
}
function commandId(logicalNodeId) {
return `fleet-kill-${logicalNodeId}-${globalThis.crypto?.randomUUID?.() || Date.now()}`;
}
async function submitKills(logicalNodeIds) {
return Promise.all(logicalNodeIds.map(async logicalNodeId => {
try {
const response = await fetch(`/api/control/nodes/${logicalNodeId}/kill`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ command_id: commandId(logicalNodeId) }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${response.status}`);
}
return { logicalNodeId, error: null };
} catch (error) {
return { logicalNodeId, error: error.message };
}
}));
}
function resultMessage(results) {
const failed = results.filter(result => result.error);
if (!failed.length) return `Termination accepted for ${results.length} node${results.length === 1 ? '' : 's'}`;
const accepted = results.length - failed.length;
const failures = failed.map(result => `${result.logicalNodeId}: ${result.error}`).join('; ');
return `${accepted} accepted; ${failed.length} failed — ${failures}`;
}
function syncFleetControl(model) {
const page = document.getElementById('page');
const cards = [...page.querySelectorAll('a.node-card[data-node]')];
if (!cards.length) {
document.getElementById(BULK_CONTROL_ID)?.remove();
return;
}
window.dispatchEvent(new CustomEvent('dashboard-hardware-source', {
detail: {
source: model?.provider?.provisioning_mode === 'mock' ? 'orchestrator' : 'node',
},
}));
const byId = new Map(managedNodes(model).map(node => [node.logical_node_id, node]));
for (const logicalNodeId of [...selectedNodes]) {
if (!killable(byId.get(logicalNodeId))) selectedNodes.delete(logicalNodeId);
}
for (const card of cards) {
const logicalNodeId = Number(card.dataset.node);
const selectable = card.dataset.origin !== 'orchestrator'
&& Number.isSafeInteger(logicalNodeId)
&& killable(byId.get(logicalNodeId));
card.dataset.bulkKillable = String(selectable);
card.dataset.bulkSelected = String(selectable && selectedNodes.has(logicalNodeId));
card.setAttribute('aria-selected', String(selectable && selectedNodes.has(logicalNodeId)));
card.title = selectable ? 'Open node; Shift+click to select for termination' : '';
}
const nodeView = document.querySelector('.node-view[data-node]');
if (!nodeView) return;
const rawNodeId = nodeView.getAttribute('data-node') || '';
if (!/^\d+$/.test(rawNodeId)) return;
const logicalNodeId = Number(rawNodeId);
const node = model?.nodes?.find(candidate => candidate.logical_node_id === logicalNodeId);
const count = selectedNodes.size;
let control = document.getElementById(BULK_CONTROL_ID);
if (!count) {
control?.remove();
return;
}
if (!control) {
control = document.createElement('div');
control.id = BULK_CONTROL_ID;
control.className = 'myelin-control myelin-bulk-control';
control.innerHTML = `<span data-selection></span>
<button type="button" class="danger" data-action="kill-selected">Terminate selected</button>
<span class="myelin-control-message" data-message role="status"></span>`;
page.querySelector('.grid').before(control);
control.querySelector('[data-action="kill-selected"]').onclick = killSelected;
}
control.querySelector('[data-selection]').textContent =
`${count} worker node${count === 1 ? '' : 's'} selected`;
const button = control.querySelector('[data-action="kill-selected"]');
button.disabled = false;
button.textContent = `Terminate ${count} selected`;
}
async function killSelected() {
const control = document.getElementById(BULK_CONTROL_ID);
const button = control?.querySelector('[data-action="kill-selected"]');
const message = control?.querySelector('[data-message]');
const byId = new Map(managedNodes(lastModel).map(node => [node.logical_node_id, node]));
const ids = [...selectedNodes].filter(logicalNodeId => killable(byId.get(logicalNodeId)));
if (!ids.length) return;
const label = ids.join(', ');
if (!await confirmTermination(
`Terminate ${ids.length} managed node${ids.length === 1 ? '' : 's'}?`,
`Terminate managed node${ids.length === 1 ? '' : 's'} ${label}? Vast.ai contracts are destroyed and billing stops.`,
`Terminate ${ids.length}`,
)) return;
button.disabled = true;
message.textContent = 'Submitting termination requests…';
const results = await submitKills(ids);
for (const result of results) {
if (!result.error) selectedNodes.delete(result.logicalNodeId);
}
message.textContent = resultMessage(results);
syncFleetControl(lastModel);
}
function ensureOrchestratorControl(nodeView, model) {
const heading = nodeView.querySelector('section.panel > h2');
if (!heading) return;
let control = document.getElementById(NODE_CONTROL_ID);
if (!control) {
control = document.createElement('div');
control.id = NODE_CONTROL_ID;
control.className = 'myelin-control';
control.innerHTML = `<button type="button" class="danger" data-action="shutdown-all">Shutdown all</button>
<span class="myelin-control-message" data-message role="status"></span>`;
heading.after(control);
control.querySelector('[data-action="shutdown-all"]').onclick = shutdownAll;
}
const count = managedNodes(model).filter(killable).length;
const button = control.querySelector('[data-action="shutdown-all"]');
button.disabled = count === 0;
button.textContent = count ? `Shutdown all (${count})` : 'No managed nodes to shut down';
}
async function shutdownAll() {
const control = document.getElementById(NODE_CONTROL_ID);
const button = control?.querySelector('[data-action="shutdown-all"]');
const message = control?.querySelector('[data-message]');
const ids = managedNodes(lastModel).filter(killable).map(node => node.logical_node_id);
if (!ids.length) return;
if (!await confirmTermination(
`Shut down all ${ids.length} managed node${ids.length === 1 ? '' : 's'}?`,
`This terminates managed nodes ${ids.join(', ')}. Vast.ai contracts are destroyed and billing stops.`,
'Shutdown all',
)) return;
button.disabled = true;
message.textContent = 'Submitting termination requests…';
const results = await submitKills(ids);
message.textContent = resultMessage(results);
}
async function syncManagedNodeControl(nodeView, model, logicalNodeId) {
const node = managedNodes(model).find(candidate => candidate.logical_node_id === logicalNodeId);
if (!node) return;
let job = { state: 'idle', message: 'No job submitted' };
@ -76,34 +219,16 @@
const heading = nodeView.querySelector('section.panel > h2');
if (!heading) return;
let control = document.getElementById(CONTROL_ID);
let control = document.getElementById(NODE_CONTROL_ID);
if (!control) {
control = document.createElement('div');
control.id = CONTROL_ID;
control.style.cssText = 'display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin:0 0 12px';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.toml,text/plain,application/toml';
fileInput.dataset.jobFile = '';
fileInput.style.cssText = 'max-width:260px;color:var(--muted);font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace';
const jobButton = document.createElement('button');
jobButton.type = 'button';
jobButton.dataset.action = 'job';
jobButton.textContent = 'Submit job';
jobButton.style.cssText = 'border:1px solid #5cd5ff;background:transparent;color:#5cd5ff;border-radius:3px;padding:6px 10px;font:600 12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;cursor:pointer';
const killButton = document.createElement('button');
killButton.type = 'button';
killButton.dataset.action = 'kill';
killButton.style.cssText = 'border:1px solid #ef4444;background:transparent;color:#ef4444;border-radius:3px;padding:6px 10px;font:600 12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;cursor:pointer';
const message = document.createElement('span');
message.dataset.message = '';
message.style.cssText = 'font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--muted)';
control.append(fileInput, jobButton, killButton, message);
control.id = NODE_CONTROL_ID;
control.className = 'myelin-control';
control.innerHTML = `<input type="file" accept=".toml,text/plain,application/toml" data-job-file>
<button type="button" data-action="job">Submit job</button>
<button type="button" class="danger" data-action="kill">Kill</button>
<span class="myelin-control-message" data-message role="status"></span>`;
control.querySelector('[data-job-file]').style.cssText = 'max-width:260px;color:var(--muted);font:12px var(--mono)';
heading.after(control);
}
@ -124,17 +249,11 @@
killButton.hidden = terminal;
killButton.disabled = pending;
killButton.textContent = pending ? 'Kill requested' : 'Kill';
killButton.style.opacity = pending ? '.55' : '1';
if (job.state !== 'idle') {
message.textContent = job.message;
} else if (selectedJob) {
message.textContent = `Selected ${selectedJob.name}`;
} else if (terminal) {
message.textContent = `managed node ${logicalNodeId}: ${node.phase}`;
} else {
message.textContent = 'Select a job TOML file';
}
if (job.state !== 'idle') message.textContent = job.message;
else if (selectedJob) message.textContent = `Selected ${selectedJob.name}`;
else if (terminal) message.textContent = `managed node ${logicalNodeId}: ${node.phase}`;
else message.textContent = 'Select a job TOML file';
fileInput.onchange = async () => {
const file = fileInput.files?.[0];
@ -145,8 +264,7 @@
return;
}
try {
const text = await file.text();
selectedJobs.set(logicalNodeId, { name: file.name, text });
selectedJobs.set(logicalNodeId, { name: file.name, text: await file.text() });
jobButton.disabled = jobActive;
message.textContent = `Selected ${file.name}`;
} catch (error) {
@ -177,30 +295,76 @@
};
killButton.onclick = async () => {
if (!await confirmKill(logicalNodeId)) return;
if (!await confirmTermination(
'Terminate managed node?',
`Terminate managed node ${logicalNodeId}? Vast.ai contracts are destroyed and billing stops.`,
'Terminate node',
)) return;
killButton.disabled = true;
message.textContent = 'Submitting kill…';
const commandId = `fleet-kill-${globalThis.crypto?.randomUUID?.() || Date.now()}`;
try {
const response = await fetch(`/api/control/nodes/${logicalNodeId}/kill`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ command_id: commandId }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${response.status}`);
}
message.textContent = 'Kill accepted';
} catch (error) {
killButton.disabled = false;
message.textContent = error.message;
}
const [result] = await submitKills([logicalNodeId]);
message.textContent = result.error ? result.error : 'Kill accepted';
if (result.error) killButton.disabled = false;
};
}
const page = document.getElementById('page');
if (page) new MutationObserver(syncControl).observe(page, { childList: true, subtree: true });
async function syncControl() {
if (syncing) return;
syncing = true;
try {
let model;
try {
const response = await fetch('/api/control/fleet', { cache: 'no-store' });
if (!response.ok) return;
model = (await response.json()).FleetStatus;
} catch (_) {
return;
}
lastModel = model;
window.dispatchEvent(new CustomEvent('dashboard-hardware-source', {
detail: { source: model?.provider?.provisioning_mode === 'mock' ? 'orchestrator' : 'node' },
}));
const nodeView = document.querySelector('.node-view[data-node]');
if (!nodeView) {
syncFleetControl(model);
return;
}
if (nodeView.dataset.origin === 'orchestrator') {
ensureOrchestratorControl(nodeView, model);
return;
}
const rawNodeId = nodeView.dataset.node || '';
if (/^\d+$/.test(rawNodeId)) await syncManagedNodeControl(nodeView, model, Number(rawNodeId));
} finally {
syncing = false;
}
}
installUi();
page.addEventListener('click', event => {
const card = event.target.closest('a.node-card[data-node]');
if (!card || !page.contains(card) || !event.shiftKey) return;
const logicalNodeId = Number(card.dataset.node);
const node = managedNodes(lastModel).find(candidate => candidate.logical_node_id === logicalNodeId);
const selectable = card.dataset.origin !== 'orchestrator'
&& Number.isSafeInteger(logicalNodeId)
&& killable(node);
if (!selectable) return;
event.preventDefault();
if (selectedNodes.has(logicalNodeId)) selectedNodes.delete(logicalNodeId);
else selectedNodes.add(logicalNodeId);
syncFleetControl(lastModel);
});
new MutationObserver(mutations => {
const dashboardChanged = mutations.some(mutation => {
const element = mutation.target.nodeType === Node.ELEMENT_NODE
? mutation.target
: mutation.target.parentElement;
return !element?.closest(`#${NODE_CONTROL_ID}, #${BULK_CONTROL_ID}`);
});
if (dashboardChanged) syncControl();
}).observe(page, { childList: true, subtree: true });
syncControl();
setInterval(syncControl, 1000);
})();

View file

@ -262,6 +262,12 @@ pub(crate) struct ManualReadModel {
pub nodes: Vec<SnapshotNode>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct FleetReadModel {
pub provider: ProviderReadiness,
pub nodes: Vec<SnapshotNode>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum EffectKind {
Create,
@ -430,6 +436,13 @@ impl ManualControl {
}
}
pub(crate) fn fleet_read_model(&self) -> FleetReadModel {
FleetReadModel {
provider: self.provider.clone(),
nodes: self.snapshot.nodes.clone(),
}
}
pub(crate) fn request_provision<F>(
&mut self,
request: ProvisionRequest,
@ -1297,6 +1310,9 @@ pub(crate) enum ManualControlMsg {
Query {
reply_to: ActorAddress,
},
QueryFleet {
reply_to: ActorAddress,
},
Flush {
reply_to: ActorAddress,
},
@ -1329,6 +1345,7 @@ pub(crate) enum ManualControlReply {
Accepted(CommandRecord),
Provider(ProviderReadiness),
Status(ManualReadModel),
FleetStatus(FleetReadModel),
Offers(Vec<OfferDto>),
Rejoined(RejoinBinding),
Flushed,
@ -1702,6 +1719,12 @@ impl ManualActorControl {
ManualControlMsg::Query { reply_to } => {
let _ = ctx.send(reply_to, ManualControlReply::Status(self.core.read_model()));
}
ManualControlMsg::QueryFleet { reply_to } => {
let _ = ctx.send(
reply_to,
ManualControlReply::FleetStatus(self.core.fleet_read_model()),
);
}
ManualControlMsg::PersistenceFinished { generation, error } => {
if self.persistence_in_flight != Some(generation) {
return;

View file

@ -8,4 +8,4 @@ Keep this crate read-only with respect to observed programs.
- It must not send control signals to observed runtimes.
- It must not require changes outside `crates/dashboard` for dashboard-only work.
Main built-in view: the fused control plane at `/` and `/view/fleet` (node cards with machine + actor rollup, per-node roster, per-actor dossier via `/api/view/fleet/detail`), backed by `host.*`, `proc.<label>.lifecycle`, `runtime.stats`, and `runtime.actors` frames when present. It is a pure frame consumer, tolerant of publisher shape. Message history is folded view-side from `messages_processed` deltas - no producer changes. The unified navbar is injected server-side from the view and application plugin-page registries; pages opt in with a `<!--swactor:nav-->` placeholder. Plugin routers remain application-owned composition inputs and must not add dashboard-owned control state.
Main built-in view: the fused control plane at `/` and `/view/fleet` (node cards with machine + actor rollup, per-node output tail and roster, per-actor dossier via `/api/view/fleet/detail`), backed by `host.*`, `proc.<label>.*`, `runtime.stats`, and sequenced `runtime.actors` census/vital/activity frames when present. It is a pure frame consumer, tolerant of publisher shape. Message history is folded view-side from `messages_processed` totals and deltas. The unified navbar is injected server-side from the view and application plugin-page registries; pages opt in with a `<!--swactor:nav-->` placeholder. Plugin routers remain application-owned composition inputs and must not add dashboard-owned control state.

View file

@ -4,7 +4,14 @@ Read-only HTML/SSE dashboard over incoming telemetry frames.
The crate owns the Axum server, bounded raw frame window, view registry, and application plugin-page registry. Component crates can keep telemetry views beside their code and register them through `DashboardHandle::register_view`. An embedding application can pass `DashboardPlugin` values to `DashboardHandle::with_plugins`; each plugin contributes an application-owned router and optional `PluginPage` metadata/HTML. The built-in control-plane view is hosted here because worker/actor/message processing is universal to swactor programs.
The control-plane page fuses machine stats and actor stats per node stream: node cards (CPU/GPU/net + actor rollup) → per-node actor roster → per-actor dossier (identity, message diet, sampled message history). The Rust type name is the actor's display name; the address is the unique key. Stale streams (silent beyond the liveness window) render in a separate collapsed pool, superseded `life` generations are evicted immediately, and the stale pool is hard-capped.
The control-plane page fuses machine stats, actor telemetry, and a 50-line
stdout/stderr tail per node stream: node cards (CPU/GPU/net + actor rollup) →
per-node actor roster and output cue → per-actor dossier. Pre-join provisioning
output is keyed by run/node identity and merges into the joined runtime card.
The Rust type name is the actor's display name; the address is the unique key.
Stale streams (silent beyond the liveness window) render in a separate collapsed
pool, superseded `life` generations are evicted immediately, and the stale pool
is hard-capped at 50.
## Routes
@ -13,7 +20,7 @@ The control-plane page fuses machine stats and actor stats per node stream: node
- `GET /api/frames` — recent raw frame window
- `GET /api/views` — registered view metadata
- `GET /view/telemetry/live` — generic live explorer over retained and incoming telemetry frames
- `GET /api/view/telemetry/live` — bounded per-stream/channel explorer snapshot
- `GET /api/view/telemetry/live` — 2,000 frames per stream/channel, newest lifetime per node, all live streams plus 50 stale streams
- `GET /view/fleet` — fused control-plane page (machine + actors per node)
- `GET /api/view/fleet` — live/stale pools with per-node machine and roster snapshot
- `GET /api/view/fleet/detail?stream=<node#life>&actor=<addr>` — bounded per-actor dossier detail (diet, history, sampled receipts)

View file

@ -13,7 +13,7 @@
//! [`STALE_POOL_CAP`]. A newer `life` generation for the same node evicts
//! older generations immediately — restarts stop accumulating.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, VecDeque};
use std::time::{Duration, Instant};
use parking_lot::RwLock;
@ -37,6 +37,9 @@ const STALE_POOL_CAP: usize = 50;
/// Stream origin of the process hosting this dashboard. Its card can never
/// go meaningfully stale: if that publisher were silent, no page would render.
const ORIGIN_ORCHESTRATOR: &str = "orchestrator";
const OUTPUT_TAIL_CAP: usize = 50;
const PROVISIONING_EVENTS: &str = "myelin.provisioning.events";
const PROVISIONING_LOG_PREFIX: &str = "myelin.provisioning.logs.node.";
/// Fused control-plane view serving `/` and `/view/fleet`.
#[derive(Default)]
@ -54,6 +57,7 @@ struct FusedNode {
last_seen: Instant,
hardware: NodeHardwareState,
actors: RuntimeState,
output: NodeOutputState,
origin: Option<String>,
label: Option<String>,
}
@ -64,6 +68,76 @@ impl FusedNode {
}
}
#[derive(Default)]
struct NodeOutputState {
lines: VecDeque<NodeOutputLine>,
phase: Option<String>,
partials: BTreeMap<(String, String), String>,
last_output: Option<Instant>,
}
#[derive(Clone, Serialize)]
struct NodeOutputLine {
source: String,
phase: String,
text: String,
}
impl NodeOutputState {
fn set_phase(&mut self, phase: impl Into<String>) {
self.phase = Some(phase.into());
}
fn push_line(&mut self, source: &str, phase: &str, text: impl Into<String>, now: Instant) {
if self.lines.len() == OUTPUT_TAIL_CAP {
self.lines.pop_front();
}
self.lines.push_back(NodeOutputLine {
source: source.to_owned(),
phase: phase.to_owned(),
text: text.into(),
});
self.phase = Some(phase.to_owned());
self.last_output = Some(now);
}
fn push_chunk(&mut self, source: &str, phase: &str, payload: &[u8], now: Instant) {
let key = (source.to_owned(), phase.to_owned());
let mut buffered = self.partials.remove(&key).unwrap_or_default();
buffered.push_str(&String::from_utf8_lossy(payload));
while let Some(newline) = buffered.find('\n') {
let mut line = buffered.drain(..=newline).collect::<String>();
line.truncate(line.trim_end_matches(['\r', '\n']).len());
self.push_line(source, phase, line, now);
}
if !buffered.is_empty() {
self.partials.insert(key, buffered);
}
if !payload.is_empty() {
self.phase = Some(phase.to_owned());
self.last_output = Some(now);
}
}
fn snapshot_lines(&self) -> Vec<NodeOutputLine> {
let mut lines = self.lines.iter().cloned().collect::<Vec<_>>();
lines.extend(
self.partials
.iter()
.filter(|(_, text)| !text.is_empty())
.map(|((source, phase), text)| NodeOutputLine {
source: source.clone(),
phase: phase.clone(),
text: text.clone(),
}),
);
if lines.len() > OUTPUT_TAIL_CAP {
lines.drain(..lines.len() - OUTPUT_TAIL_CAP);
}
lines
}
}
impl DashboardView for ControlPlaneView {
fn id(&self) -> &'static str {
"fleet"
@ -86,25 +160,35 @@ impl DashboardView for ControlPlaneView {
fn ingest(&self, _stream: &StreamId, _frame: &Frame, event: &FrameEvent) {
let now = Instant::now();
let mut state = self.state.write();
let key = stream_key(&event.stream);
let node = state.streams.entry(key).or_insert_with(|| FusedNode {
stream: event.stream.clone(),
last_seen: now,
hardware: NodeHardwareState::new(now),
actors: RuntimeState::new(now),
origin: event.stream.origin.clone(),
label: event.stream.label.clone(),
});
node.last_seen = now;
// Later events may carry descriptor metadata the first lacked.
if let Some(origin) = &event.stream.origin {
node.origin = Some(origin.clone());
if let Some(routed) = provisioning_output(event) {
{
let pending = ensure_node(&mut state.streams, &routed.stream, now);
pending.last_seen = now;
pending.output.set_phase(&routed.phase);
if let (Some(source), Some(text)) = (routed.source, routed.text) {
pending.output.push_line(&source, &routed.phase, text, now);
}
}
prune(&mut state.streams, &routed.stream, now);
}
if let Some(label) = &event.stream.label {
node.label = Some(label.clone());
{
let node = ensure_node(&mut state.streams, &event.stream, now);
node.last_seen = now;
// Later events may carry descriptor metadata the first lacked.
if let Some(origin) = &event.stream.origin {
node.origin = Some(origin.clone());
}
if let Some(label) = &event.stream.label {
node.label = Some(label.clone());
}
node.hardware.update(&event.channel, &event.payload, now);
node.actors.update(&event.channel, &event.payload, now);
if let Some((source, phase)) = process_output_channel(&event.channel) {
node.output.push_chunk(source, &phase, &event.payload, now);
}
}
node.hardware.update(&event.channel, &event.payload, now);
node.actors.update(&event.channel, &event.payload, now);
prune(&mut state.streams, &event.stream, now);
}
@ -185,6 +269,96 @@ impl DashboardView for ControlPlaneView {
}
}
struct RoutedProvisionOutput {
stream: StreamEvent,
phase: String,
source: Option<String>,
text: Option<String>,
}
fn ensure_node<'a>(
streams: &'a mut BTreeMap<String, FusedNode>,
stream: &StreamEvent,
now: Instant,
) -> &'a mut FusedNode {
streams
.entry(stream_key(stream))
.or_insert_with(|| FusedNode {
stream: stream.clone(),
last_seen: now,
hardware: NodeHardwareState::new(now),
actors: RuntimeState::new(now),
output: NodeOutputState::default(),
origin: stream.origin.clone(),
label: stream.label.clone(),
})
}
fn provisioning_output(event: &FrameEvent) -> Option<RoutedProvisionOutput> {
let value = serde_json::from_slice::<Value>(&event.payload).ok()?;
let (run_id, node_id, phase, source, text) = if event.channel == PROVISIONING_EVENTS {
let event = value.get("event")?;
let kind = event.get("kind").and_then(Value::as_str)?;
let phase = match kind {
"ProvisionStart" => "provisioning",
"NodeLive" => "joining",
"ProvisionFailed" => "failed",
"NodeStopped" => "stopped",
_ => "provisioning",
};
(
event.get("run_id").and_then(Value::as_u64)?,
event.get("node_id").and_then(Value::as_u64)?,
phase.to_owned(),
event
.get("message")
.and_then(Value::as_str)
.map(|_| "provider".to_owned()),
event
.get("message")
.and_then(Value::as_str)
.map(str::to_owned),
)
} else if event.channel.starts_with(PROVISIONING_LOG_PREFIX) {
let line = value.get("line")?;
let source = line
.get("stream")
.and_then(Value::as_str)
.unwrap_or("Provider")
.to_ascii_lowercase();
(
line.get("run_id").and_then(Value::as_u64)?,
line.get("node_id").and_then(Value::as_u64)?,
"provisioning".to_owned(),
Some(source),
line.get("line").and_then(Value::as_str).map(str::to_owned),
)
} else {
return None;
};
Some(RoutedProvisionOutput {
stream: StreamEvent {
node: node_id.to_string(),
life: run_id,
origin: Some("bootstrap".to_owned()),
label: Some("pending node".to_owned()),
},
phase,
source,
text,
})
}
fn process_output_channel(channel: &str) -> Option<(&'static str, String)> {
let label = channel.strip_prefix("proc.")?;
if let Some(phase) = label.strip_suffix(".stdout") {
return Some(("stdout", phase.to_owned()));
}
label
.strip_suffix(".stderr")
.map(|phase| ("stderr", phase.to_owned()))
}
/// Evict superseded life generations and enforce the stale-pool cap.
fn prune(streams: &mut BTreeMap<String, FusedNode>, fresh: &StreamEvent, now: Instant) {
// A newer life generation for the same node replaces older ones: the old
@ -241,6 +415,7 @@ struct NodeCard {
storage: Option<telemetry::hardware::storage::HostStorageSample>,
process: Option<crate::hardware_view::ProcessSnapshot>,
history: Vec<HardwareHistorySnapshot>,
output: NodeOutputSnapshot,
actor_summary: ActorSummarySnapshot,
/// Aggregate-only roster rows; heavy per-actor detail lives behind the
/// detail endpoint so snapshot payload stays independent of ring sizes.
@ -268,6 +443,13 @@ struct ActorSummarySnapshot {
num_workers: Option<u32>,
}
#[derive(Serialize)]
struct NodeOutputSnapshot {
phase: Option<String>,
last_output_ms_ago: Option<u64>,
lines: Vec<NodeOutputLine>,
}
#[derive(Serialize)]
struct RosterRow {
address: String,
@ -281,6 +463,7 @@ struct RosterRow {
worker_id: Option<u32>,
poisoned: bool,
last_msg_type: Option<String>,
last_active_ms_ago: Option<u64>,
}
#[derive(Serialize)]
@ -300,6 +483,7 @@ struct ActorDetail {
last_msg_type: Option<String>,
message_type_counts: Vec<MessageTypeCountSnapshot>,
history: Vec<ActorHistoryPoint>,
last_active_ms_ago: Option<u64>,
receipts: Vec<ReceiptSnapshot>,
sampled_out: u64,
}
@ -330,10 +514,15 @@ fn stream_key(stream: &StreamEvent) -> String {
fn node_card(node: &FusedNode, now: Instant) -> NodeCard {
let summary = node.hardware.summary();
let totals = node.actors.totals();
let totals = node.actors.totals_at(now);
// Address-keyed map order is the stable default. Volatile telemetry must
// not move a row out from under the pointer; the page offers explicit sorts.
let roster: Vec<RosterRow> = node.actors.actors.values().map(roster_row).collect();
let roster: Vec<RosterRow> = node
.actors
.actors
.values()
.map(|actor| roster_row(actor, now))
.collect();
NodeCard {
stream: StreamKeySnapshot {
key: stream_key(&node.stream),
@ -372,6 +561,14 @@ fn node_card(node: &FusedNode, now: Instant) -> NodeCard {
io_pressure_some_avg10: sample.io_pressure_some_avg10,
})
.collect(),
output: NodeOutputSnapshot {
phase: node.output.phase.clone(),
last_output_ms_ago: node
.output
.last_output
.map(|last| duration_ms(now.duration_since(last))),
lines: node.output.snapshot_lines(),
},
actor_summary: ActorSummarySnapshot {
actors: totals.actors,
msg_per_sec: totals.msg_per_sec,
@ -384,7 +581,7 @@ fn node_card(node: &FusedNode, now: Instant) -> NodeCard {
}
}
fn roster_row(actor: &ActorState) -> RosterRow {
fn roster_row(actor: &ActorState, now: Instant) -> RosterRow {
RosterRow {
address: actor.address.clone(),
name: actor.name.clone(),
@ -397,9 +594,12 @@ fn roster_row(actor: &ActorState) -> RosterRow {
},
mailbox_depth: actor.mailbox_depth,
messages_processed: actor.messages_processed,
msg_per_sec: actor.msg_per_sec,
msg_per_sec: actor.rate_at(now),
worker_id: actor.worker_id,
poisoned: actor.poisoned,
last_active_ms_ago: actor
.last_active
.map(|last| duration_ms(now.duration_since(last))),
last_msg_type: actor.last_msg_type.clone(),
}
}
@ -434,8 +634,11 @@ fn actor_detail(
mailbox_depth: actor.mailbox_depth,
mailbox_growth: actor.mailbox_growth,
messages_processed: actor.messages_processed,
msg_per_sec: actor.msg_per_sec,
msg_per_sec: actor.rate_at(now),
last_msg_type: actor.last_msg_type.clone(),
last_active_ms_ago: actor
.last_active
.map(|last| duration_ms(now.duration_since(last))),
message_type_counts: actor
.message_type_counts
.iter()
@ -559,6 +762,73 @@ mod tests {
assert_eq!(card["label"], json!("provisioning supervisor"));
}
#[test]
fn provisioning_tail_merges_into_joined_node_card() {
let view = ControlPlaneView::default();
let orchestrator = StreamId::new(NodeId::new("orchestrator"), Lifetime(42));
ingest_json(
&view,
&orchestrator,
0,
PROVISIONING_EVENTS,
serde_json::to_vec(&json!({
"event": {
"run_id": 42,
"node_id": 7,
"kind": "ProvisionStart",
"message": "leasing GPU"
}
}))
.unwrap(),
);
let pending = view.snapshot_json();
let pending_card = pending["live"]
.as_array()
.unwrap()
.iter()
.find(|card| card["stream"]["node"] == "7")
.expect("pending card");
assert_eq!(pending_card["stream"]["origin"], "bootstrap");
assert_eq!(pending_card["output"]["phase"], "provisioning");
ingest_json(
&view,
&orchestrator,
1,
"myelin.provisioning.logs.node.7.stdout",
serde_json::to_vec(&json!({
"line": {
"run_id": 42,
"node_id": 7,
"stream": "Stdout",
"line": "runtime starting"
}
}))
.unwrap(),
);
let remote = StreamId::new(NodeId::new("7"), Lifetime(42));
ingest_json(
&view,
&remote,
2,
"proc.job-runner-run.stdout",
b"job output\n".to_vec(),
);
let joined = view.snapshot_json();
let cards = joined["live"]
.as_array()
.unwrap()
.iter()
.filter(|card| card["stream"]["node"] == "7")
.collect::<Vec<_>>();
assert_eq!(cards.len(), 1, "join keeps one visual identity");
assert_eq!(cards[0]["output"]["phase"], "job-runner-run");
assert_eq!(cards[0]["output"]["lines"].as_array().unwrap().len(), 3);
assert_eq!(cards[0]["output"]["lines"][2]["source"], "stdout");
assert_eq!(cards[0]["output"]["lines"][2]["text"], "job output");
}
fn actors_payload(worker: u32, actors: serde_json::Value) -> Vec<u8> {
serde_json::json!({ "worker_id": worker, "actors": actors })
.to_string()

View file

@ -108,6 +108,8 @@
.empty { padding: 24px; border: 1px dashed var(--border); border-radius: var(--r); color: var(--muted); font-family: var(--mono); font-size: 12px; }
.notice { font-size: 12px; color: var(--muted); margin: 6px 0; }
canvas { width: 100%; height: 64px; background: var(--inset); border: 1px solid var(--border); border-radius: var(--r); }
.output-head { display:flex; justify-content:space-between; gap:12px; align-items:baseline; margin-bottom:8px; }
.output-tail { margin:0; height:180px; overflow:auto; padding:10px; background:var(--inset); border:1px solid var(--divider); border-radius:var(--r); color:var(--text); font:11px/1.45 var(--mono); white-space:pre-wrap; overflow-wrap:anywhere; }
.hardware-source { margin-bottom: 8px; }
.hardware-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 8px; }
.hw-card { min-width: 0; padding: 10px; background: var(--inset); border: 1px solid var(--divider); border-radius: var(--r); }
@ -316,7 +318,7 @@ function nodeCard(node) {
// The stats line (actors · msg/s · uptime) jitters every frame on busy
// supervisors; it renders as a pass-filled span so the card markup — and
// with it the whole grid — stays byte-identical between polls.
return `<a class="node-card${isOrchestrator ? ' orchestrator' : ''}" data-stream="${esc(node.stream.key)}" data-live="${node.live ? 'true' : 'false'}" href="?stream=${encodeURIComponent(node.stream.key)}">
return `<a class="node-card${isOrchestrator ? ' orchestrator' : ''}" data-stream="${esc(node.stream.key)}" data-node="${esc(node.stream.node)}" data-origin="${esc(origin)}" data-live="${node.live ? 'true' : 'false'}" href="?stream=${encodeURIComponent(node.stream.key)}">
${role}
<h3>${esc(node.stream.node)} <span class="muted" style="font-size:12px">life ${fmt(node.stream.life)}</span></h3>
<div class="meta">${proc}seen <span data-seen></span> ago · <span data-stats></span>${label}</div>
@ -362,11 +364,15 @@ function renderNode(page, node, live, stale) {
const html = `
<a class="focus-back" href="/">← Fleet (<span data-live-count></span> live, <span data-stale-count></span> stale)</a>
<div class="layout ${selectedActor ? 'with-dossier' : ''}">
<div class="node-view" data-node="${esc(node.stream.node)}">
<div class="node-view" data-node="${esc(node.stream.node)}" data-origin="${esc(node.stream.origin || '')}">
<section class="panel">
<h2 style="margin:0 0 8px">${esc(node.stream.node)}${node.stream.label ? ` <span class="muted" style="font-size:13px">${esc(node.stream.label)}</span>` : ''} <span class="muted" style="font-size:13px">life ${fmt(node.stream.life)} · <span data-seen></span></span></h2>
<div id="machine-detail"></div>
</section>
<section class="panel">
<div class="output-head"><h2 style="margin:0">Node output</h2><span class="muted mono" id="output-meta"></span></div>
<pre class="output-tail" id="node-output" aria-label="Latest node stdout and stderr"></pre>
</section>
<section class="panel">
<h2 style="margin:0 0 8px">Actors (<span data-actor-count></span>)</h2>
<input type="search" id="roster-filter" aria-label="Filter actors" placeholder="filter by type, address, worker…" value="${esc(rosterFilter)}">
@ -402,6 +408,7 @@ function renderNode(page, node, live, stale) {
machineSlot.innerHTML = machine;
lastMachineHtml = machine;
}
renderNodeOutput(node);
renderRoster(node);
if (selectedActor) {
if (!detailTimer) startDetailPolling();
@ -410,6 +417,24 @@ function renderNode(page, node, live, stale) {
}
}
function renderNodeOutput(node) {
const tail = document.getElementById('node-output');
const meta = document.getElementById('output-meta');
if (!tail || !meta) return;
const output = node.output || {};
const lines = output.lines || [];
const wasAtBottom = tail.dataset.initialized !== 'true'
|| tail.scrollHeight - tail.clientHeight - tail.scrollTop < 8;
tail.textContent = lines.length
? lines.map(line => `[${line.source || '?'} · ${line.phase || '?'}] ${line.text || ''}`).join('\n')
: 'No output observed yet.';
tail.dataset.initialized = 'true';
if (wasAtBottom) tail.scrollTop = tail.scrollHeight;
const phase = output.phase || 'waiting';
const age = output.last_output_ms_ago == null ? 'no output yet' : `last output ${ago(output.last_output_ms_ago)} ago`;
meta.textContent = `${phase} · ${age}`;
}
function percentOf(used, total) {
const numerator = Number(used), denominator = Number(total);
return Number.isFinite(numerator) && Number.isFinite(denominator) && denominator > 0
@ -614,7 +639,7 @@ function renderRoster(node) {
<td data-rate></td>
<td data-processed></td>
<td>${a.worker_id == null ? '—' : fmt(a.worker_id)}</td>
<td><span class="muted" data-last-message></span></td>
<td><span class="muted" data-last-message></span><br><span class="muted" data-last-active></span></td>
</tr>`).join('')}</tbody>
</table>`;
}
@ -638,6 +663,9 @@ function updateRosterTexts(wrap, actors) {
last.title = messageType;
const parts = String(messageType).split('::');
last.textContent = messageType ? parts[parts.length - 1] || messageType : '—';
row.querySelector('[data-last-active]').textContent = actor.last_active_ms_ago == null
? 'never active'
: `${ago(actor.last_active_ms_ago)} ago`;
}
}
@ -707,6 +735,7 @@ function renderDossier(d) {
<span class="k">throughput</span><span>${fmtRate(d.msg_per_sec)} msg/s</span>
<span class="k">processed</span><span>${fmt(d.messages_processed)}</span>
<span class="k">last message</span><span class="mono" title="${esc(d.last_msg_type || '')}">${esc(d.last_msg_type || '—')}</span>
<span class="k">last active</span><span>${d.last_active_ms_ago == null ? 'never' : `${ago(d.last_active_ms_ago)} ago`}</span>
</div>
<h2>Throughput</h2>
<canvas id="dossier-spark" width="380" height="64"></canvas>

View file

@ -1,4 +1,5 @@
use std::collections::{BTreeMap, VecDeque};
use std::time::{Duration, Instant};
use parking_lot::RwLock;
use serde_json::{Value, json};
@ -8,12 +9,93 @@ use crate::FrameEvent;
use crate::view::DashboardView;
const LIVE_EXPLORER_HTML: &str = include_str!("live_explorer_page.html");
const FRAME_HISTORY_CAP: usize = 500;
const FRAME_HISTORY_CAP: usize = 2_000;
const LIVE_TTL: Duration = Duration::from_secs(8);
const STALE_STREAM_CAP: usize = 50;
#[derive(Default)]
pub struct LiveTelemetryExplorer {
state: RwLock<BTreeMap<(String, u64, String), VecDeque<FrameEvent>>>,
state: RwLock<ExplorerState>,
}
#[derive(Default)]
struct ExplorerState {
streams: BTreeMap<(String, u64), ExplorerStream>,
}
struct ExplorerStream {
last_seen: Instant,
channels: BTreeMap<String, VecDeque<FrameEvent>>,
}
impl LiveTelemetryExplorer {
fn ingest_at(&self, event: &FrameEvent, now: Instant) {
let mut state = self.state.write();
let newest_life = state
.streams
.keys()
.filter(|(node, _)| node == &event.stream.node)
.map(|(_, life)| *life)
.max();
if newest_life.is_some_and(|life| event.stream.life < life) {
return;
}
if newest_life.is_none_or(|life| event.stream.life > life) {
state
.streams
.retain(|(node, _), _| node != &event.stream.node);
}
let stream = state
.streams
.entry((event.stream.node.clone(), event.stream.life))
.or_insert_with(|| ExplorerStream {
last_seen: now,
channels: BTreeMap::new(),
});
stream.last_seen = now;
let frames = stream.channels.entry(event.channel.clone()).or_default();
if frames.len() >= FRAME_HISTORY_CAP {
frames.pop_front();
}
frames.push_back(event.clone());
prune_stale(&mut state.streams, now);
}
fn snapshot_at(&self, now: Instant) -> Value {
let mut state = self.state.write();
prune_stale(&mut state.streams, now);
let mut frames = state
.streams
.values()
.flat_map(|stream| stream.channels.values())
.flat_map(|frames| frames.iter())
.collect::<Vec<_>>();
frames.sort_by(|left, right| {
left.stream
.node
.cmp(&right.stream.node)
.then_with(|| left.stream.life.cmp(&right.stream.life))
.then_with(|| left.position.cmp(&right.position))
});
json!({ "frames": frames })
}
}
fn prune_stale(streams: &mut BTreeMap<(String, u64), ExplorerStream>, now: Instant) {
let mut stale = streams
.iter()
.filter(|(_, stream)| now.duration_since(stream.last_seen) > LIVE_TTL)
.map(|(key, stream)| (key.clone(), stream.last_seen))
.collect::<Vec<_>>();
if stale.len() <= STALE_STREAM_CAP {
return;
}
let excess = stale.len() - STALE_STREAM_CAP;
stale.sort_by_key(|(_, last_seen)| *last_seen);
for (key, _) in stale.into_iter().take(excess) {
streams.remove(&key);
}
}
impl DashboardView for LiveTelemetryExplorer {
@ -30,34 +112,11 @@ impl DashboardView for LiveTelemetryExplorer {
}
fn ingest(&self, _stream: &StreamId, _frame: &Frame, event: &FrameEvent) {
let key = (
event.stream.node.clone(),
event.stream.life,
event.channel.clone(),
);
let mut state = self.state.write();
let frames = state.entry(key).or_default();
if frames.len() >= FRAME_HISTORY_CAP {
frames.pop_front();
}
frames.push_back(event.clone());
self.ingest_at(event, Instant::now());
}
fn snapshot_json(&self) -> Value {
let state = self.state.read();
let mut frames = state
.values()
.flat_map(|frames| frames.iter())
.collect::<Vec<_>>();
frames.sort_by(|left, right| {
left.stream
.node
.cmp(&right.stream.node)
.then_with(|| left.stream.life.cmp(&right.stream.life))
.then_with(|| left.position.cmp(&right.position))
});
json!({ "frames": frames })
self.snapshot_at(Instant::now())
}
fn html(&self) -> Option<&'static str> {
@ -104,29 +163,54 @@ mod tests {
}
#[test]
fn retains_same_channel_separately_across_stream_lifetimes() {
fn newer_lifetime_evicts_older_lifetime_and_rejects_late_frames() {
let view = LiveTelemetryExplorer::default();
let first = StreamId::new(NodeId::new("node-a"), Lifetime(1));
let second = StreamId::new(NodeId::new("node-a"), Lifetime(2));
ingest(&view, &first, "host.cpu", 7);
ingest(&view, &second, "host.cpu", 0);
ingest(&view, &first, "host.cpu", 8);
let snapshot = view.snapshot_json();
let frames = snapshot["frames"].as_array().expect("frames array");
assert_eq!(frames.len(), 2);
assert_eq!(frames[0]["stream"]["life"].as_u64(), Some(1));
assert_eq!(frames[0]["position"].as_u64(), Some(7));
assert_eq!(frames[1]["stream"]["life"].as_u64(), Some(2));
assert_eq!(frames[1]["position"].as_u64(), Some(0));
assert_eq!(frames.len(), 1);
assert_eq!(frames[0]["stream"]["life"].as_u64(), Some(2));
assert_eq!(frames[0]["position"].as_u64(), Some(0));
}
fn ingest(view: &LiveTelemetryExplorer, stream: &StreamId, channel: &str, position: u64) {
let frame = Frame::new(
#[test]
fn retains_all_live_streams_and_only_fifty_stale_streams() {
let view = LiveTelemetryExplorer::default();
let start = Instant::now();
for node in 0..60 {
let stream = StreamId::new(NodeId::new(format!("node-{node}")), Lifetime(1));
let frame = test_frame(node);
let event = test_event(&stream, "host.cpu", node, &frame);
view.ingest_at(&event, start + Duration::from_millis(node));
}
let snapshot = view.snapshot_at(start + LIVE_TTL + Duration::from_secs(1));
let streams = snapshot["frames"]
.as_array()
.expect("frames array")
.iter()
.map(|frame| frame["stream"]["node"].as_str().unwrap())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(streams.len(), STALE_STREAM_CAP);
assert!(!streams.contains("node-0"));
assert!(streams.contains("node-59"));
}
fn test_frame(position: u64) -> Frame {
Frame::new(
ChannelId(position as u32 + 1),
Position(position),
position.to_le_bytes().to_vec(),
);
let event = FrameEvent {
)
}
fn test_event(stream: &StreamId, channel: &str, position: u64, frame: &Frame) -> FrameEvent {
FrameEvent {
stream: crate::StreamEvent {
node: stream.node.as_str().to_string(),
life: stream.life.0,
@ -136,7 +220,12 @@ mod tests {
channel: channel.to_owned(),
position,
payload: frame.payload.clone(),
};
}
}
fn ingest(view: &LiveTelemetryExplorer, stream: &StreamId, channel: &str, position: u64) {
let frame = test_frame(position);
let event = test_event(stream, channel, position, &frame);
view.ingest(stream, &frame, &event);
}
}

View file

@ -48,6 +48,8 @@ tr[data-active="true"]{background:var(--selected);box-shadow:inset 2px 0 0 var(-
.tag.binary{color:var(--magenta);border-color:var(--magenta)}
.json{color:var(--ok)}.text{color:var(--amber)}.binary{color:var(--magenta)}
.ok{color:var(--ok)}.warn{color:var(--amber)}.bad{color:var(--bad)}
.connection-state{display:grid;justify-items:end;gap:4px;max-width:min(420px,45vw)}
.status-detail{font:11px/1.3 var(--mono);text-align:right;overflow-wrap:anywhere}
.kv{display:grid;grid-template-columns:115px 1fr;gap:8px;border-bottom:1px solid var(--divider);padding:7px 0;font-size:12px;font-family:var(--mono)}
pre{white-space:pre-wrap;word-break:break-word;font-family:var(--mono);background:var(--inset);border:1px solid var(--border);border-radius:var(--r);padding:12px;max-height:560px;overflow:auto;font-size:12px}
.empty{border:1px dashed var(--border);border-radius:var(--r);padding:22px;color:var(--muted);font-family:var(--mono);font-size:12px}
@ -71,13 +73,15 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
<h1>Telemetry live explorer</h1>
<div class="muted">Auto-connected. Retained channel history is restored from /api/view/telemetry/live, then live frames continue from /events.</div>
</div>
<span class="pill warn" id="status" role="status" aria-live="polite">starting</span>
<div class="connection-state">
<span class="pill warn" id="status" role="status" aria-live="polite">starting</span>
<span class="status-detail muted" id="statusDetail"></span>
</div>
</header>
<section class="top">
<button data-level="streams" aria-pressed="true">Streams</button>
<button data-level="channels" aria-pressed="false">Channels</button>
<button data-level="frames" aria-pressed="false">Frames</button>
<button id="clearScope" aria-label="Reset scope to all streams">all streams</button>
<input id="filter" aria-label="Filter visible rows" placeholder="filter visible rows" spellcheck="false">
<select id="kind"><option value="">all payloads</option><option value="json">json</option><option value="text">text</option><option value="binary">binary</option></select>
<label class="muted" for="limit">keep/channel</label>
@ -136,6 +140,7 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
selectedFrame: '',
connected: false,
error: '',
notice: '',
messageVersion: 0,
totalFrames: 0,
jsonFrames: 0,
@ -338,9 +343,12 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
}
function updateMetrics() {
const status = state.error ? 'error' : state.connected ? 'live' : 'connecting';
const status = state.error ? 'reconnecting' : state.notice ? 'resyncing' : state.connected ? 'live' : 'connecting';
const detail = state.error || state.notice;
nodes.status.textContent = status;
nodes.status.className = `pill ${status === 'live' ? 'ok' : status === 'error' ? 'bad' : 'warn'}`;
nodes.status.className = `pill ${status === 'live' ? 'ok' : 'warn'}`;
nodes.statusDetail.textContent = detail;
nodes.status.title = detail;
setSeg('metricFramesSeg', state.totalFrames);
setSeg('metricStreamsSeg', state.streams.size);
setSeg('metricChannelsSeg', state.channels.size);
@ -511,21 +519,20 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
async function reconcileSnapshot({ notice = '', minimumNoticeMs = 0 } = {}) {
if (notice) {
state.error = notice;
state.notice = notice;
state.messageVersion++;
updateMetrics();
renderDetail();
}
const messageVersion = state.messageVersion;
const startedAt = performance.now();
try {
const response = await fetch('/api/view/telemetry/live', { cache: 'no-store' });
if (!response.ok) throw new Error(`/api/view/telemetry/live HTTP ${response.status}`);
if (!response.ok) throw new Error(`history restore failed: HTTP ${response.status}`);
const snapshot = await response.json();
if (!snapshot || !Array.isArray(snapshot.frames)) {
throw new Error('/api/view/telemetry/live returned an invalid snapshot');
throw new Error('history restore returned invalid data');
}
for (const event of snapshot.frames) addFrame(event);
for (const frame of snapshot.frames) addFrame(frame);
flushNow();
const remainingNoticeMs = minimumNoticeMs - (performance.now() - startedAt);
@ -534,13 +541,15 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
}
if (state.messageVersion === messageVersion) {
state.error = '';
state.notice = '';
state.messageVersion++;
updateMetrics();
renderDetail();
}
} catch (error) {
if (state.messageVersion === messageVersion) {
state.error = error.message;
state.error = error.message || 'history restore failed';
state.notice = '';
state.messageVersion++;
updateMetrics();
renderDetail();
@ -557,6 +566,7 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
if (state.source !== source) return;
state.connected = true;
state.error = '';
state.notice = '';
state.messageVersion++;
updateMetrics();
reconcileSnapshot();
@ -578,10 +588,8 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
source.onerror = () => {
if (state.source !== source) return;
state.connected = false;
if (!state.totalFrames) {
state.error = 'waiting for /events';
state.messageVersion++;
}
state.notice = 'Telemetry stream disconnected; retrying automatically.';
state.messageVersion++;
updateMetrics();
};
}
@ -621,12 +629,12 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
if (row) selectFrame(row.dataset.frame);
});
nodes.clearScope.addEventListener('click', () => selectStream(''));
nodes.filter.addEventListener('input', applyFilter);
nodes.kind.addEventListener('change', renderSelectedFrames);
nodes.limit.addEventListener('change', trimAllChannels);
nodes.reconnect.addEventListener('click', () => {
state.error = '';
state.notice = '';
state.messageVersion++;
connectEvents();
updateMetrics();

View file

@ -32,6 +32,7 @@ const HISTORY_CAP: usize = 512;
const HISTORY_MIN_INTERVAL: Duration = Duration::from_millis(250);
const PER_ACTOR_HISTORY_CAP: usize = 120;
const GROWTH_WINDOW: usize = 12;
const ACTIVITY_RATE_WINDOW: Duration = Duration::from_secs(3);
/// Sampled message-receipt ring bound (view side).
pub(crate) const RECEIPT_CAP: usize = 16;
/// Minimum spacing between receipts; bounds ring churn for noisy actors.
@ -54,6 +55,9 @@ pub(crate) struct RuntimeState {
pub(crate) actors: BTreeMap<String, ActorState>,
pub(crate) history: VecDeque<HistorySample>,
actor_snapshot_generation: u64,
telemetry_generation: Option<u64>,
telemetry_sequence: Option<u64>,
telemetry_discontinuities: u64,
}
impl RuntimeState {
@ -65,6 +69,9 @@ impl RuntimeState {
actors: BTreeMap::new(),
history: VecDeque::with_capacity(HISTORY_CAP),
actor_snapshot_generation: 0,
telemetry_generation: None,
telemetry_sequence: None,
telemetry_discontinuities: 0,
}
}
@ -74,13 +81,85 @@ impl RuntimeState {
return;
};
match channel {
RUNTIME_ACTORS => self.apply_actors(&value, now),
RUNTIME_ACTORS if self.accept_protocol_frame(&value) => {
match value.get("kind").and_then(Value::as_str) {
Some("census") => self.apply_actors(&value, now),
Some("activity") => self.apply_activity(&value, now),
Some("vital") => self.apply_vital(&value, now),
_ => self.apply_actors(&value, now),
}
}
RUNTIME_STATS => self.apply_stats(&value, now),
_ => {}
}
self.push_history(now);
}
fn accept_protocol_frame(&mut self, value: &Value) -> bool {
let Some(generation) = value.get("generation").and_then(Value::as_u64) else {
return true;
};
match self.telemetry_generation {
Some(current) if generation < current => return false,
Some(current) if generation > current => {
self.actors.clear();
self.telemetry_sequence = None;
}
_ => {}
}
self.telemetry_generation = Some(generation);
let Some(sequence) = value.get("sequence").and_then(Value::as_u64) else {
return true;
};
if let Some(previous) = self.telemetry_sequence {
if sequence <= previous {
return false;
}
if sequence != previous.wrapping_add(1) {
self.telemetry_discontinuities = self.telemetry_discontinuities.saturating_add(1);
}
}
self.telemetry_sequence = Some(sequence);
true
}
fn apply_vital(&mut self, value: &Value, now: Instant) {
let Some(actor) = value.get("actor") else {
return;
};
let address = string_field(actor, &["address", "addr", "actor_addr"]);
if value.get("event").and_then(Value::as_str) == Some("stopped") {
if let Some(address) = address {
self.actors.remove(&address);
}
return;
}
let worker = u32_field(value, &["worker_id", "worker"]);
let _ = self.apply_actor(actor, now, worker);
}
fn apply_activity(&mut self, value: &Value, now: Instant) {
let worker = u32_field(value, &["worker_id", "worker"]);
let interval = value
.get("interval_ms")
.and_then(Value::as_u64)
.map(|ms| Duration::from_millis(ms).as_secs_f64())
.unwrap_or(0.0);
let Some(actors) = value.get("actors").and_then(Value::as_array) else {
return;
};
for record in actors {
let Some(address) = string_field(record, &["address", "addr", "actor_addr"]) else {
continue;
};
self.actors
.entry(address.clone())
.or_insert_with(|| ActorState::new(address))
.apply_activity(record, now, worker, interval);
}
}
fn apply_actors(&mut self, value: &Value, now: Instant) {
// A wrapped actor list is a complete snapshot for one worker. A list
// without a worker is a complete merged-runtime snapshot. Bare actor
@ -186,6 +265,12 @@ impl RuntimeState {
}
totals
}
pub(crate) fn totals_at(&self, now: Instant) -> Totals {
let mut totals = self.totals();
totals.msg_per_sec = self.actors.values().map(|actor| actor.rate_at(now)).sum();
totals
}
}
#[derive(Clone)]
@ -207,9 +292,18 @@ pub(crate) struct ActorState {
/// Messages folded away by the receipt sampling interval.
pub(crate) sampled_out: u64,
pub(crate) last_update: Option<Instant>,
pub(crate) last_active: Option<Instant>,
activity_rates: VecDeque<ActivityRateSample>,
snapshot_generation: u64,
}
#[derive(Clone)]
struct ActivityRateSample {
at: Instant,
interval: f64,
delta: u64,
}
impl ActorState {
fn new(address: String) -> Self {
Self {
@ -229,6 +323,8 @@ impl ActorState {
receipts: VecDeque::with_capacity(RECEIPT_CAP),
sampled_out: 0,
last_update: None,
last_active: None,
activity_rates: VecDeque::new(),
snapshot_generation: 0,
}
}
@ -282,9 +378,81 @@ impl ActorState {
self.messages_processed.saturating_sub(processed_before),
);
self.push_history(now);
if self.messages_processed > processed_before {
self.last_active = Some(now);
}
self.recompute_growth();
}
fn apply_activity(
&mut self,
value: &Value,
now: Instant,
default_worker: Option<u32>,
interval: f64,
) {
if let Some(worker_id) = default_worker {
self.worker_id = Some(worker_id);
}
let delta = u64_field(value, &["messages_processed_delta"]).unwrap_or(0);
self.messages_processed = self.messages_processed.saturating_add(delta);
if delta > 0 {
self.last_active = Some(now);
}
if interval > 0.0 {
self.activity_rates.push_back(ActivityRateSample {
at: now,
interval,
delta,
});
while self
.activity_rates
.front()
.is_some_and(|sample| now.duration_since(sample.at) > ACTIVITY_RATE_WINDOW)
{
self.activity_rates.pop_front();
}
}
self.msg_per_sec = self.rate_at(now);
assign_u32(&mut self.mailbox_depth, value, &["mailbox_depth", "queued"]);
if let Some(last) = string_field(
value,
&["last_msg_type", "last_message", "last_message_type"],
)
.filter(|last| !last.is_empty())
{
self.last_msg_type = Some(last);
}
if let Some(poisoned) = value.get("poisoned").and_then(Value::as_bool) {
self.poisoned = poisoned;
}
self.last_update = Some(now);
self.fold_receipt(now, delta);
self.push_history(now);
self.recompute_growth();
}
pub(crate) fn rate_at(&self, now: Instant) -> f64 {
if self.activity_rates.is_empty() {
return self.msg_per_sec;
}
let mut messages = 0_u64;
let mut seconds = 0.0;
for sample in self
.activity_rates
.iter()
.filter(|sample| now.duration_since(sample.at) <= ACTIVITY_RATE_WINDOW)
{
messages = messages.saturating_add(sample.delta);
seconds += sample.interval;
}
if seconds > 0.0 {
messages as f64 / seconds
} else {
0.0
}
}
/// Fold a `messages_processed` jump into the sampled receipt ring. The
/// receipt carries the last message type visible on the frame; messages
/// hidden inside the jump (or skipped by the interval) are counted, not
@ -464,6 +632,10 @@ mod tests {
use super::*;
fn apply_protocol(runtime: &mut RuntimeState, value: Value, at: Instant) {
runtime.update(RUNTIME_ACTORS, value.to_string().as_bytes(), at);
}
#[test]
fn per_worker_snapshots_remove_stopped_actors_without_touching_other_workers() {
let now = Instant::now();
@ -518,4 +690,80 @@ mod tests {
vec!["other-worker"]
);
}
#[test]
fn folds_census_activity_and_vital_records_in_sequence() {
let now = Instant::now();
let mut runtime = RuntimeState::new(now);
apply_protocol(
&mut runtime,
json!({
"kind": "census",
"generation": 7,
"sequence": 1,
"worker_id": 0,
"actors": [{
"address": "actor-a",
"actor_type": "ActorA",
"message_type": "Ping",
"messages_processed": 10,
"mailbox_depth": 1,
"poisoned": false
}]
}),
now,
);
apply_protocol(
&mut runtime,
json!({
"kind": "activity",
"generation": 7,
"sequence": 2,
"worker_id": 0,
"interval_ms": 500,
"actors": [{
"address": "actor-a",
"messages_processed_delta": 4,
"mailbox_depth": 2,
"mailbox_max_depth": 5,
"last_msg_type": "Ping"
}]
}),
now + Duration::from_millis(500),
);
let actor = runtime.actors.get("actor-a").unwrap();
assert_eq!(actor.messages_processed, 14);
assert_eq!(actor.mailbox_depth, 2);
assert_eq!(actor.msg_per_sec, 8.0);
assert_eq!(actor.rate_at(now + Duration::from_secs(4)), 0.0);
assert_eq!(actor.last_active, Some(now + Duration::from_millis(500)));
apply_protocol(
&mut runtime,
json!({
"kind": "vital",
"generation": 7,
"sequence": 3,
"worker_id": 0,
"event": "stopped",
"actor": {"address": "actor-a"}
}),
now + Duration::from_millis(600),
);
assert!(runtime.actors.is_empty());
apply_protocol(
&mut runtime,
json!({
"kind": "activity",
"generation": 7,
"sequence": 2,
"worker_id": 0,
"interval_ms": 250,
"actors": [{"address": "actor-a", "messages_processed_delta": 99}]
}),
now + Duration::from_millis(700),
);
assert!(runtime.actors.is_empty(), "stale sequence is ignored");
}
}

View file

@ -62,6 +62,21 @@ pub struct ObservedProbeEvent {
pub consecutive_timeouts: u32,
}
/// One-second aggregate of routine successful probe traffic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObservedProbeSummary {
pub interval_ms: u64,
pub sent: u64,
pub acked: u64,
pub direct_sent: u64,
pub indirect_sent: u64,
pub rtt_samples: u64,
pub rtt_ms_min: Option<u32>,
pub rtt_ms_p50: Option<u32>,
pub rtt_ms_p95: Option<u32>,
pub rtt_ms_max: Option<u32>,
}
/// Current probe state for one peer.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PeerProbeState {
@ -85,6 +100,12 @@ struct Inner {
probe_events: VecDeque<ObservedProbeEvent>,
/// Transitions awaiting drain by the node's telemetry tick.
transitions: VecDeque<ObservedTransition>,
summary_started: Option<Instant>,
summary_sent: u64,
summary_acked: u64,
summary_direct_sent: u64,
summary_indirect_sent: u64,
summary_rtts: Vec<u32>,
}
/// The installed SWIM observer plus the readouts the node consumes each tick.
@ -147,6 +168,50 @@ impl SwimTelemetry {
.collect()
}
/// Drain one aggregate after at least one second. Routine sent/acked
/// observations never enter the immediate-event queue.
pub fn drain_probe_summary(&self) -> Option<ObservedProbeSummary> {
let now = Instant::now();
let mut inner = self.inner.lock().expect("swim telemetry poisoned");
let started = inner.summary_started.get_or_insert(now);
let elapsed = now.duration_since(*started);
if elapsed < Duration::from_secs(1) {
return None;
}
inner.summary_started = Some(now);
if inner.summary_sent == 0 && inner.summary_acked == 0 {
return None;
}
inner.summary_rtts.sort_unstable();
let percentile = |numerator: usize, denominator: usize| {
if inner.summary_rtts.is_empty() {
None
} else {
let last = inner.summary_rtts.len() - 1;
Some(inner.summary_rtts[last * numerator / denominator])
}
};
let summary = ObservedProbeSummary {
interval_ms: elapsed.as_millis().min(u64::MAX as u128) as u64,
sent: inner.summary_sent,
acked: inner.summary_acked,
direct_sent: inner.summary_direct_sent,
indirect_sent: inner.summary_indirect_sent,
rtt_samples: inner.summary_rtts.len() as u64,
rtt_ms_min: inner.summary_rtts.first().copied(),
rtt_ms_p50: percentile(1, 2),
rtt_ms_p95: percentile(95, 100),
rtt_ms_max: inner.summary_rtts.last().copied(),
};
inner.summary_sent = 0;
inner.summary_acked = 0;
inner.summary_direct_sent = 0;
inner.summary_indirect_sent = 0;
inner.summary_rtts.clear();
Some(summary)
}
/// Snapshot the probe state for one peer without draining events.
pub fn peer_probe_state(&self, peer: NodeId) -> PeerProbeState {
let inner = self.inner.lock().expect("swim telemetry poisoned");
@ -184,27 +249,23 @@ impl SwimTelemetry {
inner.targets.pop_front();
}
inner.targets.push_back(target);
let state = Self::peer_probe_state_locked(&inner, target);
Self::push_probe_event(
&mut inner,
ObservedProbeEvent {
event: "sent",
target,
sequence,
kind,
rtt_ms: None,
budget_ms: None,
last_ack_age: state.last_ack_age,
consecutive_timeouts: state.consecutive_timeouts,
},
);
inner.summary_started.get_or_insert_with(Instant::now);
inner.summary_sent = inner.summary_sent.saturating_add(1);
match kind {
"direct" => {
inner.summary_direct_sent = inner.summary_direct_sent.saturating_add(1);
}
"indirect" => {
inner.summary_indirect_sent = inner.summary_indirect_sent.saturating_add(1);
}
_ => {}
}
}
SwimObservation::ProbeAcked {
target,
sequence,
kind,
kind: _,
} => {
let state = Self::peer_probe_state_locked(&inner, target);
let rtt_ms = inner
.in_flight
.remove(&(target, sequence))
@ -217,19 +278,10 @@ impl SwimTelemetry {
}
inner.last_ack.insert(target, Instant::now());
inner.consecutive_timeouts.remove(&target);
Self::push_probe_event(
&mut inner,
ObservedProbeEvent {
event: "acked",
target,
sequence,
kind,
rtt_ms,
budget_ms: None,
last_ack_age: state.last_ack_age,
consecutive_timeouts: state.consecutive_timeouts,
},
);
inner.summary_acked = inner.summary_acked.saturating_add(1);
if let Some(rtt) = rtt_ms {
inner.summary_rtts.push(rtt);
}
}
SwimObservation::ProbeTimedOut {
target,

View file

@ -86,6 +86,26 @@ pub struct SwimProbeEvent {
pub lifeguard_enabled: bool,
}
/// One-second aggregate of routine SWIM sent/acked traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SwimProbeSummary {
pub event: String,
pub interval_ms: u64,
pub sent: u64,
pub acked: u64,
pub direct_sent: u64,
pub indirect_sent: u64,
pub rtt_samples: u64,
pub rtt_ms_min: Option<u32>,
pub rtt_ms_p50: Option<u32>,
pub rtt_ms_p95: Option<u32>,
pub rtt_ms_max: Option<u32>,
#[serde(default)]
pub local_phase: String,
#[serde(default)]
pub probe_interval_ms: u64,
}
/// Consolidated distribution-subsystem state.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DistributionState {
@ -142,6 +162,9 @@ impl Record for MembershipTransition {
impl Record for SwimProbeEvent {
const CHANNEL: &'static str = SWIM_PROBES;
}
impl Record for SwimProbeSummary {
const CHANNEL: &'static str = SWIM_PROBES;
}
impl Record for DistributionState {
const CHANNEL: &'static str = DIST_STATE;
}

View file

@ -249,15 +249,21 @@ mod snapshot_and_swim_telemetry {
});
let events = telemetry.drain_probe_events();
assert_eq!(events.len(), 4);
assert_eq!(events[0].event, "sent");
assert_eq!(events[1].event, "acked");
assert!(events[1].rtt_ms.is_some());
assert_eq!(events[2].event, "sent");
assert_eq!(events[3].event, "timed_out");
assert_eq!(events[3].budget_ms, Some(15_000));
assert_eq!(events[3].rtt_ms, None);
assert_eq!(events[3].consecutive_timeouts, 1);
assert_eq!(events.len(), 1, "only timeout remains immediate");
assert_eq!(events[0].event, "timed_out");
assert_eq!(events[0].budget_ms, Some(15_000));
assert_eq!(events[0].rtt_ms, None);
assert_eq!(events[0].consecutive_timeouts, 1);
assert!(telemetry.drain_probe_events().is_empty());
std::thread::sleep(Duration::from_millis(1_010));
let summary = telemetry
.drain_probe_summary()
.expect("one-second routine probe aggregate");
assert_eq!(summary.sent, 2);
assert_eq!(summary.acked, 1);
assert_eq!(summary.direct_sent, 2);
assert_eq!(summary.rtt_samples, 1);
assert!(summary.rtt_ms_p50.is_some());
}
}

View file

@ -14,6 +14,7 @@ swactor = { path = "../..", features = ["serde"] }
swactor-transport = { path = "../transport" }
swactor-process = { path = "../process" }
swactor-engine = { path = "../engine" }
telemetry = { path = "../telemetry" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tar = "0.4"

View file

@ -81,6 +81,7 @@ pub struct NodeJobActor {
output_sink: Option<SharedJobEdgeSink>,
data_plane: Option<Arc<dyn JobDataPlanePort>>,
route_registrar: Option<Arc<dyn JobRouteRegistrar>>,
process_telemetry: Option<telemetry::TelemetryProducer>,
data_plane_env: BTreeMap<String, String>,
data_plane_error: Option<String>,
data_plane_pending: bool,
@ -117,6 +118,7 @@ impl NodeJobActor {
output_sink: None,
data_plane: None,
route_registrar: None,
process_telemetry: None,
data_plane_env: BTreeMap::new(),
data_plane_error: None,
data_plane_pending: false,
@ -143,6 +145,11 @@ impl NodeJobActor {
self
}
pub fn with_process_telemetry(mut self, producer: telemetry::TelemetryProducer) -> Self {
self.process_telemetry = Some(producer);
self
}
/// Edge mode: workspace bytes arrive over EDGE_ALPN and are extracted by the
/// integration layer, which sets `flag` once the workspace is on disk.
pub fn with_workspace_ready(mut self, flag: Arc<AtomicBool>) -> Self {
@ -246,12 +253,11 @@ impl NodeJobActor {
working_dir: Some(self.workdir.clone()),
label: Some(format!("job-runner-{:?}", phase).to_lowercase()),
};
if let Err(e) = spawn_local_process(
ctx,
&self.sender,
spec,
ProcessOutputConfig::disabled(relay),
) {
let output = self.process_telemetry.clone().map_or_else(
|| ProcessOutputConfig::disabled(relay),
|producer| ProcessOutputConfig::telemetry_mirror(relay, producer),
);
if let Err(e) = spawn_local_process(ctx, &self.sender, spec, output) {
self.emit(
ctx,
NodeJobEvent::NodeFault {

View file

@ -71,8 +71,9 @@ ProcessOutputConfig::observability(&self) -> ProcessLifecycleObservability
`disabled` sends only upstream `ProcessOutput`.
`telemetry_mirror` sends upstream `ProcessOutput` and also mirrors each
lifecycle/control output to one telemetry channel.
`telemetry_mirror` sends every `ProcessOutput` upstream, mirrors lifecycle
records to `proc.<label>.lifecycle`, and mirrors raw child bytes to
`proc.<label>.stdout` and `proc.<label>.stderr`.
### 1.3 ProcessLifecycleObservability
@ -81,8 +82,8 @@ ProcessLifecycleObservability::Disabled
ProcessLifecycleObservability::TelemetryMirror
```
This setting controls lifecycle/control mirroring only. It does not enable child
stdin/stdout/stderr handling.
This setting controls telemetry mirroring. Child stdout/stderr are always
delivered upstream; the mirror additionally publishes those bytes.
### 1.4 ProcessCommand
@ -98,6 +99,8 @@ present, is the grace duration before kill escalation.
### 1.5 ProcessOutput
```text
ProcessOutput::Stdout(Vec<u8>)
ProcessOutput::Stderr(Vec<u8>)
ProcessOutput::Started { pid: u32 }
ProcessOutput::SpawnFailed { error: String }
ProcessOutput::Exited { status: ExitStatus }
@ -187,17 +190,18 @@ cmd.args(&spec.args)
cmd.env(key, value) for each spec.env entry
cmd.current_dir(dir) when spec.working_dir is Some(dir)
cmd.stdin(Stdio::null())
cmd.stdout(Stdio::null())
cmd.stderr(Stdio::null())
cmd.stdout(Stdio::piped())
cmd.stderr(Stdio::piped())
cmd.spawn()
```
The crate does not invoke a shell unless the caller explicitly sets `command` to
a shell executable and supplies shell arguments.
Child stdin/stdout/stderr are connected to null handles. The managed-process
protocol does not expose stdin writes, stdout/stderr output events, PTY resize,
or arbitrary signal commands.
Child stdin is connected to a null handle. Stdout and stderr are piped to
bounded-read threads and delivered as `ProcessOutput::Stdout` /
`ProcessOutput::Stderr` before the terminal output. The protocol does not expose
stdin writes, PTY resize, or arbitrary signal commands.
If `cmd.spawn()` fails, the actor emits exactly one terminal
`ProcessOutput::SpawnFailed { error }` and does not emit `Started`, `Exited`, or
@ -209,13 +213,14 @@ If `cmd.spawn()` fails, the actor emits exactly one terminal
The actor sends each public `ProcessOutput` to the configured upstream actor.
When `ProcessOutputConfig::telemetry_mirror` is used, the actor also mirrors
each output to the configured telemetry producer. Telemetry submit failure is
ignored and does not suppress upstream output or emit `ProcessOutput::Error`.
When `ProcessOutputConfig::telemetry_mirror` is used, lifecycle outputs are
encoded as JSON on `proc.<label>.lifecycle`; stdout/stderr retain their real byte
content on text-stream channels. Telemetry submit failure is ignored and does
not suppress upstream output or emit `ProcessOutput::Error`.
Public lifecycle/control output order follows observed lifecycle:
- successful spawn emits `Started` before any terminal `Exited`;
- successful spawn emits `Started`, then any stdout/stderr chunks, before terminal `Exited`;
- spawn failure emits `SpawnFailed` without `Started` or `Exited`;
- supervisor failure emits `Error`;
- after a terminal output, later public stop attempts emit no additional

View file

@ -167,6 +167,16 @@ impl ProcessActor {
self.state = ProcessActorState::Done(ProcessDoneState::Exited(status));
}
}
ThreadEvent::Output { stderr, bytes } => {
self.emit_process_output(
ctx,
if stderr {
ProcessOutput::Stderr(bytes)
} else {
ProcessOutput::Stdout(bytes)
},
);
}
ThreadEvent::Error { error } => {
if !matches!(self.state, ProcessActorState::Done(_)) {
self.emit_process_output(

View file

@ -54,30 +54,54 @@ pub(crate) struct PreparedProcessOutput {
}
pub(crate) struct LifecycleTelemetryMirror {
channel: telemetry::ChannelId,
lifecycle: telemetry::ChannelId,
stdout: telemetry::ChannelId,
stderr: telemetry::ChannelId,
producer: TelemetryProducer,
}
impl LifecycleTelemetryMirror {
pub(crate) fn submit(&self, output: &ProcessOutput) {
let payload = match output {
ProcessOutput::Started { pid } => json!({"event": "started", "pid": pid}),
ProcessOutput::SpawnFailed { error } => {
json!({"event": "spawn_failed", "error": error})
match output {
ProcessOutput::Stdout(bytes) => {
let _ = self.producer.submit_bytes(self.stdout, bytes.clone());
}
ProcessOutput::Exited { status } => match status {
ExitStatus::Code(value) => {
json!({"event": "exited", "status": {"kind": "code", "value": value}})
}
ExitStatus::Signal(value) => {
json!({"event": "exited", "status": {"kind": "signal", "value": value}})
}
ExitStatus::Unknown => json!({"event": "exited", "status": {"kind": "unknown"}}),
},
ProcessOutput::Error { error } => json!({"event": "error", "error": error}),
};
let bytes = serde_json::to_vec(&payload).expect("process lifecycle record serializes");
let _ = self.producer.submit_bytes(self.channel, bytes);
ProcessOutput::Stderr(bytes) => {
let _ = self.producer.submit_bytes(self.stderr, bytes.clone());
}
output => {
let payload = match output {
ProcessOutput::Started { pid } => json!({"event": "started", "pid": pid}),
ProcessOutput::SpawnFailed { error } => {
json!({"event": "spawn_failed", "error": error})
}
ProcessOutput::Exited { status } => match status {
ExitStatus::Code(value) => {
json!({"event": "exited", "status": {"kind": "code", "value": value}})
}
ExitStatus::Signal(value) => {
json!({"event": "exited", "status": {"kind": "signal", "value": value}})
}
ExitStatus::Unknown => {
json!({"event": "exited", "status": {"kind": "unknown"}})
}
},
ProcessOutput::Error { error } => json!({"event": "error", "error": error}),
ProcessOutput::Stdout(_) | ProcessOutput::Stderr(_) => unreachable!(),
};
let bytes =
serde_json::to_vec(&payload).expect("process lifecycle record serializes");
let _ = self.producer.submit_bytes(self.lifecycle, bytes);
}
}
}
}
fn channel_registration_error(error: telemetry::ChannelRegistrationError) -> swactor::Error {
match error {
telemetry::ChannelRegistrationError::ConflictingName { name } => swactor::Error::from(
format!("conflicting telemetry channel registration for {name}"),
),
}
}
@ -98,25 +122,29 @@ pub(crate) fn prepare_process_output(
.expect("telemetry mirror config stores producer");
let reservation =
LifecycleLabelReservation::reserve(producer.stream_id().clone(), &label)?;
let channel_name = label.channel_name();
let channel = producer
let lifecycle = producer
.try_register_channel(
channel_name,
label.channel_name(),
ChannelContent::JsonRecord {
schema: Some("swactor_process.lifecycle.v1".to_owned()),
},
)
.map_err(|err| match err {
telemetry::ChannelRegistrationError::ConflictingName { name } => {
swactor::Error::from(format!(
"conflicting telemetry channel registration for {name}"
))
}
})?;
.map_err(channel_registration_error)?;
let stdout = producer
.try_register_channel(label.stdout_channel_name(), ChannelContent::TextStream)
.map_err(channel_registration_error)?;
let stderr = producer
.try_register_channel(label.stderr_channel_name(), ChannelContent::TextStream)
.map_err(channel_registration_error)?;
Ok(PreparedProcessOutput {
upstream: config.upstream,
mirror: Some(LifecycleTelemetryMirror { channel, producer }),
mirror: Some(LifecycleTelemetryMirror {
lifecycle,
stdout,
stderr,
producer,
}),
_label_reservation: Some(reservation),
})
}
@ -134,6 +162,14 @@ impl LifecycleLabel {
pub(crate) fn channel_name(&self) -> String {
format!("proc.{}.lifecycle", self.0)
}
pub(crate) fn stdout_channel_name(&self) -> String {
format!("proc.{}.stdout", self.0)
}
pub(crate) fn stderr_channel_name(&self) -> String {
format!("proc.{}.stderr", self.0)
}
}
pub(crate) fn derive_lifecycle_label(spec: &ProcessSpec) -> Result<LifecycleLabel, swactor::Error> {

View file

@ -11,6 +11,8 @@ pub enum ProcessCommand {
/// Public managed-process outputs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProcessOutput {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Started { pid: u32 },
SpawnFailed { error: String },
Exited { status: ExitStatus },

View file

@ -1,4 +1,4 @@
use std::io;
use std::io::{self, Read};
use std::mem;
use std::os::fd::RawFd;
use std::process::{Child, Command, Stdio};
@ -21,6 +21,7 @@ pub(crate) enum ThreadEvent {
Started { pid: u32 },
SpawnFailed { error: String },
Exited { status: ExitStatus },
Output { stderr: bool, bytes: Vec<u8> },
Error { error: String },
ThreadFinished,
}
@ -308,6 +309,7 @@ fn supervisor_thread_main(
let pid: Option<u32>;
let mut kill_deadline: Option<Instant> = None;
let mut kill_sent = false;
let mut output_threads = Vec::new();
debug_assert!(matches!(state, SupervisorState::Spawning));
let mut cmd = Command::new(&spec.command);
@ -319,12 +321,18 @@ fn supervisor_thread_main(
cmd.current_dir(dir);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
.stdout(Stdio::piped())
.stderr(Stdio::piped());
match cmd.spawn() {
Ok(spawned_child) => {
Ok(mut spawned_child) => {
let child_pid = spawned_child.id();
if let Some(stdout) = spawned_child.stdout.take() {
output_threads.push(spawn_output_reader(stdout, events.clone(), false));
}
if let Some(stderr) = spawned_child.stderr.take() {
output_threads.push(spawn_output_reader(stderr, events.clone(), true));
}
pid = Some(child_pid);
child = Some(spawned_child);
events.push(ThreadEvent::Started { pid: child_pid });
@ -339,11 +347,11 @@ fn supervisor_thread_main(
) {
CommandOutcome::Continue => {}
CommandOutcome::Exited(status) => {
finish_with_exit(&events, &mut state, &mut child, status);
finish_with_exit(&events, &mut state, &mut child, &mut output_threads, status);
return;
}
CommandOutcome::Error(error) => {
finish_with_error(&events, &mut state, &mut child, error);
finish_with_error(&events, &mut state, &mut child, &mut output_threads, error);
return;
}
}
@ -367,6 +375,7 @@ fn supervisor_thread_main(
&events,
&mut state,
&mut child,
&mut output_threads,
format!("process supervisor command wake failed: {err}"),
);
return;
@ -381,11 +390,23 @@ fn supervisor_thread_main(
) {
CommandOutcome::Continue => {}
CommandOutcome::Exited(status) => {
finish_with_exit(&events, &mut state, &mut child, status);
finish_with_exit(
&events,
&mut state,
&mut child,
&mut output_threads,
status,
);
return;
}
CommandOutcome::Error(error) => {
finish_with_error(&events, &mut state, &mut child, error);
finish_with_error(
&events,
&mut state,
&mut child,
&mut output_threads,
error,
);
return;
}
}
@ -396,6 +417,7 @@ fn supervisor_thread_main(
&events,
&mut state,
&mut child,
&mut output_threads,
format!("process supervisor command wake failed: {err}"),
);
return;
@ -404,12 +426,12 @@ fn supervisor_thread_main(
match try_wait_pid(child_pid) {
Ok(Some(status)) => {
finish_with_exit(&events, &mut state, &mut child, status);
finish_with_exit(&events, &mut state, &mut child, &mut output_threads, status);
return;
}
Ok(None) => {}
Err(error) => {
finish_with_error(&events, &mut state, &mut child, error);
finish_with_error(&events, &mut state, &mut child, &mut output_threads, error);
return;
}
}
@ -422,11 +444,11 @@ fn supervisor_thread_main(
continue;
}
CommandOutcome::Exited(status) => {
finish_with_exit(&events, &mut state, &mut child, status);
finish_with_exit(&events, &mut state, &mut child, &mut output_threads, status);
return;
}
CommandOutcome::Error(error) => {
finish_with_error(&events, &mut state, &mut child, error);
finish_with_error(&events, &mut state, &mut child, &mut output_threads, error);
return;
}
}
@ -539,15 +561,44 @@ fn send_kill_or_observe_exit(pid: u32) -> CommandOutcome {
}
}
fn spawn_output_reader(
mut reader: impl Read + Send + 'static,
events: ThreadEventSink,
stderr: bool,
) -> JoinHandle<()> {
thread::spawn(move || {
let mut buffer = vec![0_u8; 4_096];
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(read) => events.push(ThreadEvent::Output {
stderr,
bytes: buffer[..read].to_vec(),
}),
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
})
}
fn join_output_readers(readers: &mut Vec<JoinHandle<()>>) {
for reader in readers.drain(..) {
let _ = reader.join();
}
}
fn finish_with_exit(
events: &ThreadEventSink,
state: &mut SupervisorState,
child: &mut Option<Child>,
output_threads: &mut Vec<JoinHandle<()>>,
status: ExitStatus,
) {
*state = SupervisorState::Done;
debug_assert!(matches!(*state, SupervisorState::Done));
let _ = child.take();
join_output_readers(output_threads);
events.push(ThreadEvent::Exited { status });
events.push(ThreadEvent::ThreadFinished);
}
@ -556,11 +607,17 @@ fn finish_with_error(
events: &ThreadEventSink,
state: &mut SupervisorState,
child: &mut Option<Child>,
output_threads: &mut Vec<JoinHandle<()>>,
error: String,
) {
*state = SupervisorState::Done;
debug_assert!(matches!(*state, SupervisorState::Done));
if let Some(child) = child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
let _ = child.take();
join_output_readers(output_threads);
events.push(ThreadEvent::Error { error });
events.push(ThreadEvent::ThreadFinished);
}
@ -877,7 +934,7 @@ mod tests {
}
#[test]
fn supervisor_uses_null_stdio_and_reports_only_lifecycle() {
fn supervisor_captures_stdout_and_stderr_before_lifecycle_completion() {
let (sink, receiver) = thread_event_channel(|| {});
let mut handle = ProcessSupervisorThread::start(
shell_spec("echo stdout; echo stderr >&2; exit 0"),
@ -888,13 +945,22 @@ mod tests {
let events = collect_until_finished(&receiver, Duration::from_secs(2));
join_finished(&mut handle);
assert_eq!(events.len(), 3);
assert!(matches!(
events.first(),
Some(ThreadEvent::Started { pid }) if *pid > 0
));
let exit = events
.iter()
.position(|event| matches!(event, ThreadEvent::Exited { .. }))
.expect("exited event");
assert!(events[..exit]
.iter()
.any(|event| matches!(event, ThreadEvent::Output { stderr: false, bytes } if bytes == b"stdout\n")));
assert!(events[..exit]
.iter()
.any(|event| matches!(event, ThreadEvent::Output { stderr: true, bytes } if bytes == b"stderr\n")));
assert_eq!(
events.get(1),
events.get(exit),
Some(&ThreadEvent::Exited {
status: ExitStatus::Code(0)
})

View file

@ -291,12 +291,8 @@ fn lifecycle_outputs_are_sent_upstream_and_mirrored_to_telemetry() {
.any(|name| name == "proc.trainer_0_foo.lifecycle"),
"catalog should contain lifecycle channel, got {names:?}"
);
assert!(
names
.iter()
.all(|name| !name.contains("stdout") && !name.contains("stderr")),
"process core should not register stdout/stderr channels: {names:?}"
);
assert!(names.iter().any(|name| name == "proc.trainer_0_foo.stdout"));
assert!(names.iter().any(|name| name == "proc.trainer_0_foo.stderr"));
let payloads: Vec<Value> = telemetry_events
.iter()
@ -322,13 +318,15 @@ fn lifecycle_outputs_are_sent_upstream_and_mirrored_to_telemetry() {
}),
"lifecycle mirror should include exited JSON, got {payloads:?}"
);
assert!(
payloads.iter().all(|payload| {
payload.get("event") != Some(&Value::String("stdout".to_owned()))
&& payload.get("event") != Some(&Value::String("stderr".to_owned()))
}),
"lifecycle mirror should not include child output events: {payloads:?}"
);
let text_payloads = telemetry_events
.iter()
.filter_map(|event| match event {
TelemetryEvent::Frame(delivery) => String::from_utf8(delivery.payload.clone()).ok(),
_ => None,
})
.collect::<Vec<_>>();
assert!(text_payloads.iter().any(|payload| payload == "stdout\n"));
assert!(text_payloads.iter().any(|payload| payload == "stderr\n"));
}
#[test]
@ -948,7 +946,7 @@ fn lifecycle_mirror_submit_failure_does_not_suppress_upstream_or_emit_error() {
}
#[test]
fn stdout_and_stderr_writes_do_not_affect_lifecycle() {
fn stdout_and_stderr_are_delivered_without_affecting_lifecycle() {
let (rt, mut host) = runtime_host();
let sender = rt.create_sender();
let upstream = rt.new_inbox::<ProcessOutput>().unwrap();
@ -983,11 +981,26 @@ fn stdout_and_stderr_writes_do_not_affect_lifecycle() {
has_exited(outputs, ExitStatus::Code(0))
});
assert_eq!(
outputs.len(),
2,
"process core should emit only lifecycle outputs: {outputs:?}"
);
let stdout = outputs
.iter()
.filter_map(|output| match output {
ProcessOutput::Stdout(bytes) => Some(bytes.as_slice()),
_ => None,
})
.flatten()
.copied()
.collect::<Vec<_>>();
let stderr = outputs
.iter()
.filter_map(|output| match output {
ProcessOutput::Stderr(bytes) => Some(bytes.as_slice()),
_ => None,
})
.flatten()
.copied()
.collect::<Vec<_>>();
assert_eq!(String::from_utf8(stdout).unwrap().lines().count(), 1_000);
assert_eq!(String::from_utf8(stderr).unwrap().lines().count(), 1_000);
assert!(started_index(&outputs) < exited_index(&outputs));
assert!(
has_exited(&outputs, ExitStatus::Code(0)),

View file

@ -408,14 +408,32 @@ trait ProcessOutputObserver {
}
trait StatsHook {
fn on_tick(&self, worker_id: usize, snapshots: &[ActorSnapshot]);
fn on_snapshot(
&self,
worker_id: usize,
snapshots: &[ActorSnapshot],
kind: StatsSnapshotKind,
);
}
```
Process output uses a caller-provided closure from `(label, is_stderr)` to an
already registered `ChannelId`. Runtime stats default to `runtime.actors` as a
JSON record channel. Other subsystems follow the same rule: register or reuse a
channel id, encode their own payload, submit bytes.
already registered `ChannelId`. Runtime actor telemetry defaults to
`runtime.actors`. Its JSON records use `swactor.actor-telemetry.v1` and carry
the stream lifetime as `generation` plus a monotonic `sequence`:
- `census` is a complete per-worker actor list, emitted initially and every
15 seconds for reconciliation;
- `vital` immediately reports actor start/stop, poison/recovery, and mailbox
pressure transitions;
- `activity` contains only changed actors, processed-message deltas, current
and interval-maximum mailbox depth, and the last message type, at no more
than four records per second per worker.
The hook receives a complete in-memory worker snapshot only when one of those
lanes is due; actors never receive reporting messages. Other subsystems follow
the same rule: register or reuse a channel id, encode their own payload, and
submit bytes.
### 4.4 The mux: single position authority
@ -633,8 +651,8 @@ not be contradicted:
|----------------|-----------------|
| `telemetry.health` | telemetry self-health counters |
| `host.cpu`, `host.gpu`, `host.net` | host hardware samples |
| `runtime.actors` | runtime actor stats hook output |
| `proc.<label>.stdout`, `proc.<label>.stderr` | managed process output |
| `runtime.actors` | sequenced actor census, vital events, and sampled activity |
| `proc.<label>.lifecycle`, `.stdout`, `.stderr` | managed process lifecycle and real output |
| `mvp.lifecycle` | MVP lifecycle facts |
| `mvp.provisioning.events` | MVP provisioning lifecycle facts |
| `mvp.provisioning.logs.node.<id>.<stream>` | MVP provisioning stdout/stderr/provider lines |

View file

@ -7,6 +7,7 @@
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use crossbeam_channel::{
Receiver, RecvError, RecvTimeoutError, Sender, TryRecvError, TrySendError, bounded,
@ -14,7 +15,7 @@ use crossbeam_channel::{
use serde::{Deserialize, Serialize};
use swactor::process_observer::ProcessOutputObserver;
use swactor::stats::{ActorSnapshot, StatsHook};
use swactor::stats::{ActorSnapshot, StatsHook, StatsSnapshotKind};
use crate::emit::ProcessChannelRouter;
use crate::frame::{
@ -28,6 +29,8 @@ use crate::transport::Delivery;
const DEFAULT_MUX_CAPACITY: usize = 4096;
const DEFAULT_SUBSCRIBER_CAPACITY: usize = 1024;
const DEFAULT_STATS_CHANNEL: &str = "runtime.actors";
const ACTOR_MAILBOX_WARNING: usize = 1_024;
const ACTOR_MAILBOX_RECOVERY: usize = 512;
/// Stable handle identifying a local telemetry subscription.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
@ -686,6 +689,7 @@ impl TelemetryProducer {
Arc::new(TelemetryStatsHook {
producer: self.clone(),
channel,
state: Mutex::new(TelemetryStatsState::default()),
})
}
}
@ -732,53 +736,256 @@ impl ProcessOutputObserver for TelemetryProcessObserver {
}
}
/// Runtime stats hook that submits one JSON record per productive worker tick.
/// Runtime actor observer implementing the census/vital/activity protocol.
pub struct TelemetryStatsHook {
producer: TelemetryProducer,
channel: ChannelId,
state: Mutex<TelemetryStatsState>,
}
impl StatsHook for TelemetryStatsHook {
fn on_tick(&self, worker_id: usize, snapshots: &[ActorSnapshot]) {
let payload = RuntimeActorStatsRecord::from_snapshots(worker_id, snapshots);
let bytes = serde_json::to_vec(&payload).expect("runtime stats record serializes");
self.producer.submit_bytes(self.channel, bytes);
}
#[derive(Default)]
struct TelemetryStatsState {
sequence: u64,
workers: BTreeMap<usize, WorkerTelemetryState>,
}
#[derive(Debug, Serialize)]
struct RuntimeActorStatsRecord<'a> {
worker_id: usize,
actors: Vec<RuntimeActorSnapshotRecord<'a>>,
#[derive(Default)]
struct WorkerTelemetryState {
actors: BTreeMap<String, RetainedActor>,
last_activity: Option<Instant>,
}
impl<'a> RuntimeActorStatsRecord<'a> {
fn from_snapshots(worker_id: usize, snapshots: &'a [ActorSnapshot]) -> Self {
#[derive(Clone)]
struct RetainedActor {
mailbox_depth: usize,
mailbox_max_depth: usize,
last_msg_type: Option<&'static str>,
actor_type: Option<&'static str>,
message_type: Option<&'static str>,
messages_processed: u64,
poisoned: bool,
under_pressure: bool,
activity_baseline: u64,
message_type_counts: Vec<(&'static str, u64)>,
}
impl RetainedActor {
fn from_snapshot(snapshot: &ActorSnapshot, previous: Option<&Self>) -> Self {
let under_pressure = match previous {
Some(previous) if previous.under_pressure => {
snapshot.mailbox_depth > ACTOR_MAILBOX_RECOVERY
}
_ => snapshot.mailbox_max_depth >= ACTOR_MAILBOX_WARNING,
};
Self {
worker_id,
actors: snapshots
mailbox_depth: snapshot.mailbox_depth,
mailbox_max_depth: snapshot.mailbox_max_depth,
last_msg_type: snapshot.last_msg_type,
actor_type: snapshot.actor_type,
message_type: snapshot.message_type,
messages_processed: snapshot.messages_processed,
poisoned: snapshot.poisoned,
under_pressure,
activity_baseline: previous
.map(|actor| actor.activity_baseline)
.unwrap_or(snapshot.messages_processed),
message_type_counts: snapshot.message_type_counts.clone(),
}
}
fn full_record(&self, address: String) -> RuntimeActorSnapshotRecord {
RuntimeActorSnapshotRecord {
address,
mailbox_depth: self.mailbox_depth,
last_msg_type: self.last_msg_type,
actor_type: self.actor_type,
message_type: self.message_type,
messages_processed: self.messages_processed,
poisoned: self.poisoned,
message_type_counts: self
.message_type_counts
.iter()
.map(|snapshot| RuntimeActorSnapshotRecord {
address: snapshot.address.to_full_hex(),
mailbox_depth: snapshot.mailbox_depth,
last_msg_type: snapshot.last_msg_type,
actor_type: snapshot.actor_type,
message_type: snapshot.message_type,
messages_processed: snapshot.messages_processed,
poisoned: snapshot.poisoned,
message_type_counts: snapshot
.message_type_counts
.iter()
.map(|(ty, count)| RuntimeMessageTypeCount { ty, count: *count })
.collect(),
})
.map(|(ty, count)| RuntimeMessageTypeCount { ty, count: *count })
.collect(),
}
}
}
impl StatsHook for TelemetryStatsHook {
fn on_snapshot(&self, worker_id: usize, snapshots: &[ActorSnapshot], kind: StatsSnapshotKind) {
let now = Instant::now();
let generation = self.producer.stream_id().life.0;
let mut state = self.state.lock().expect("actor telemetry state poisoned");
let mut payloads = Vec::new();
{
let worker = state.workers.entry(worker_id).or_default();
let previous = std::mem::take(&mut worker.actors);
let mut current = BTreeMap::new();
let mut full_state_emitted = BTreeMap::new();
for snapshot in snapshots {
let address = snapshot.address.to_full_hex();
let before = previous.get(&address);
let actor = RetainedActor::from_snapshot(snapshot, before);
match before {
None => {
payloads.push(json_record(
"vital",
worker_id,
serde_json::json!({
"event": "started",
"actor": actor.full_record(address.clone()),
}),
));
full_state_emitted.insert(address.clone(), ());
}
Some(before) => {
if before.poisoned != actor.poisoned {
payloads.push(json_record(
"vital",
worker_id,
serde_json::json!({
"event": if actor.poisoned { "poisoned" } else { "recovered" },
"actor": actor.full_record(address.clone()),
}),
));
full_state_emitted.insert(address.clone(), ());
}
if before.under_pressure != actor.under_pressure {
payloads.push(json_record(
"vital",
worker_id,
serde_json::json!({
"event": if actor.under_pressure {
"mailbox_pressure"
} else {
"mailbox_recovered"
},
"actor": actor.full_record(address.clone()),
"warning_depth": ACTOR_MAILBOX_WARNING,
"recovery_depth": ACTOR_MAILBOX_RECOVERY,
}),
));
full_state_emitted.insert(address.clone(), ());
}
}
}
current.insert(address, actor);
}
for (address, actor) in &previous {
if !current.contains_key(address) {
payloads.push(json_record(
"vital",
worker_id,
serde_json::json!({
"event": "stopped",
"actor": actor.full_record(address.clone()),
}),
));
}
}
if kind.census {
payloads.push(json_record(
"census",
worker_id,
serde_json::json!({
"actors": current
.iter()
.map(|(address, actor)| actor.full_record(address.clone()))
.collect::<Vec<_>>(),
}),
));
}
if kind.activity && !kind.census {
let interval_ms = worker
.last_activity
.map(|last| now.duration_since(last).as_millis().min(u64::MAX as u128) as u64)
.unwrap_or(0);
let mut actors = Vec::new();
for (address, actor) in &current {
if full_state_emitted.contains_key(address) {
continue;
}
let before = previous.get(address);
let delta = actor
.messages_processed
.saturating_sub(actor.activity_baseline);
let mailbox_changed =
before.is_none_or(|before| before.mailbox_depth != actor.mailbox_depth);
let max_depth = actor.mailbox_max_depth;
if delta > 0 || mailbox_changed || max_depth > actor.mailbox_depth {
actors.push(RuntimeActorActivityRecord {
address: address.clone(),
messages_processed_delta: delta,
mailbox_depth: actor.mailbox_depth,
mailbox_max_depth: max_depth,
last_msg_type: actor.last_msg_type,
poisoned: actor.poisoned.then_some(true),
});
}
}
if !actors.is_empty() {
payloads.push(json_record(
"activity",
worker_id,
serde_json::json!({
"interval_ms": interval_ms,
"actors": actors,
}),
));
}
}
if kind.activity {
worker.last_activity = Some(now);
}
for (address, actor) in &mut current {
if kind.census
|| kind.activity
|| full_state_emitted.contains_key(address)
|| !previous.contains_key(address)
{
actor.activity_baseline = actor.messages_processed;
}
}
worker.actors = current;
}
for mut payload in payloads {
state.sequence = state.sequence.wrapping_add(1);
let object = payload
.as_object_mut()
.expect("actor telemetry record is an object");
object.insert("generation".to_owned(), generation.into());
object.insert("sequence".to_owned(), state.sequence.into());
let bytes = serde_json::to_vec(&payload).expect("actor telemetry record serializes");
self.producer.submit_bytes(self.channel, bytes);
}
}
}
fn json_record(
kind: &'static str,
worker_id: usize,
fields: serde_json::Value,
) -> serde_json::Value {
let mut record = serde_json::json!({
"protocol": "swactor.actor-telemetry.v1",
"kind": kind,
"worker_id": worker_id,
});
if let (Some(record), Some(fields)) = (record.as_object_mut(), fields.as_object()) {
record.extend(fields.clone());
}
record
}
#[derive(Debug, Serialize)]
struct RuntimeActorSnapshotRecord<'a> {
struct RuntimeActorSnapshotRecord {
address: String,
mailbox_depth: usize,
last_msg_type: Option<&'static str>,
@ -786,12 +993,23 @@ struct RuntimeActorSnapshotRecord<'a> {
message_type: Option<&'static str>,
messages_processed: u64,
poisoned: bool,
message_type_counts: Vec<RuntimeMessageTypeCount<'a>>,
message_type_counts: Vec<RuntimeMessageTypeCount>,
}
#[derive(Debug, Serialize)]
struct RuntimeMessageTypeCount<'a> {
ty: &'a str,
struct RuntimeActorActivityRecord {
address: String,
messages_processed_delta: u64,
mailbox_depth: usize,
mailbox_max_depth: usize,
last_msg_type: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
poisoned: Option<bool>,
}
#[derive(Debug, Serialize)]
struct RuntimeMessageTypeCount {
ty: &'static str,
count: u64,
}

View file

@ -2,7 +2,7 @@ use std::time::Duration;
use serde_json::Value;
use swactor::actor::ActorAddress;
use swactor::stats::ActorSnapshot;
use swactor::stats::{ActorSnapshot, StatsSnapshotKind};
use telemetry::frame::{FrameDelivery, TelemetryEvent};
use telemetry::{
ChannelContent, ChannelContentKind, ChannelFilter, ChannelId, Lifetime, NodeId, Position,
@ -299,6 +299,7 @@ fn stats_hook_adapter_submits_worker_snapshot_json() {
let snapshots = [ActorSnapshot {
address: actor,
mailbox_depth: 3,
mailbox_max_depth: 4,
last_msg_type: Some("Ping"),
actor_type: Some("TestActor"),
message_type: Some("Ping"),
@ -307,14 +308,23 @@ fn stats_hook_adapter_submits_worker_snapshot_json() {
message_type_counts: vec![("Ping", 5)],
}];
hook.on_tick(2, &snapshots);
hook.on_snapshot(2, &snapshots, StatsSnapshotKind::CENSUS);
endpoint.tick();
let event = sub.recv_timeout(Duration::from_millis(50)).unwrap();
let delivery = frame_event(&event);
let events = sub.drain_available();
assert_eq!(events.len(), 2, "started vital plus recovery census");
let delivery = events
.iter()
.map(frame_event)
.find(|delivery| {
serde_json::from_slice::<Value>(&delivery.payload)
.is_ok_and(|json| json["kind"] == "census")
})
.expect("census frame");
assert_eq!(delivery.channel.channel, runtime);
let json: Value = serde_json::from_slice(&delivery.payload).unwrap();
assert_eq!(json["worker_id"], 2);
assert_eq!(json["generation"], 7);
assert_eq!(json["actors"][0]["address"], actor.to_full_hex());
assert_eq!(json["actors"][0]["mailbox_depth"], 3);
assert_eq!(json["actors"][0]["last_msg_type"], "Ping");

View file

@ -2,7 +2,7 @@ use crate::Error;
use crate::actor::{ActorAddress, Message, SpawnRequest};
use crate::channel::{AsyncSender, Sender};
use crate::config::RuntimeConfig;
use crate::stats::{StatsHook, WorkerStats};
use crate::stats::WorkerStats;
use parking_lot::RwLock;
use std::any::Any;
use std::collections::{HashMap, HashSet};
@ -209,7 +209,6 @@ pub(crate) struct TickContext<'a> {
pub(crate) extension: Option<&'a dyn crate::extension::RuntimeExtension>,
pub(crate) process_output_observer:
Option<&'a Arc<dyn crate::process_observer::ProcessOutputObserver>>,
pub(crate) stats_hook: Option<&'a dyn StatsHook>,
pub(crate) worker_stats: &'a WorkerStats,
pub(crate) num_workers: usize,
pub(crate) worker_id: WorkerId,

View file

@ -105,10 +105,40 @@ pub struct WorkerInfo {
pub stops: u64,
}
/// Per-actor snapshot transferred from worker to runtime (not serialized).
/// Why a worker is publishing a complete in-memory snapshot to its observer.
///
/// The reason lets observers emit sparse activity without losing immediate
/// lifecycle changes or periodic recovery censuses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StatsSnapshotKind {
pub activity: bool,
pub census: bool,
}
impl StatsSnapshotKind {
pub const VITAL: Self = Self {
activity: false,
census: false,
};
pub const ACTIVITY: Self = Self {
activity: true,
census: false,
};
pub const CENSUS: Self = Self {
activity: false,
census: true,
};
pub const ACTIVITY_AND_CENSUS: Self = Self {
activity: true,
census: true,
};
}
/// Snapshot of one actor transferred from worker to observer (not serialized).
pub struct ActorSnapshot {
pub address: ActorAddress,
pub mailbox_depth: usize,
pub mailbox_max_depth: usize,
pub last_msg_type: Option<&'static str>,
pub actor_type: Option<&'static str>,
pub message_type: Option<&'static str>,
@ -118,16 +148,16 @@ pub struct ActorSnapshot {
pub message_type_counts: Vec<(&'static str, u64)>,
}
/// Observer hook called by workers after productive ticks.
/// Observer hook called with rate-limited, worker-owned actor snapshots.
///
/// Implement this to collect per-actor snapshot data outside the runtime.
/// The runtime itself stores nothing — snapshots are ephemeral and passed by reference.
/// The runtime stores no observer state. Snapshots are ephemeral and borrowed;
/// observers copy only what they retain.
pub trait StatsHook: Send + Sync {
/// Called once per worker after a productive tick.
/// Called for immediate vital changes, activity sampling, periodic census,
/// or a combined activity/census deadline.
///
/// `worker_id` is the index of the worker (0..num_workers).
/// `snapshots` borrows the worker's scratch buffer — copy what you need.
fn on_tick(&self, worker_id: usize, snapshots: &[ActorSnapshot]);
fn on_snapshot(&self, worker_id: usize, snapshots: &[ActorSnapshot], kind: StatsSnapshotKind);
}
/// Per-actor runtime stats.

View file

@ -5,6 +5,7 @@ use std::cmp::Reverse;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use crate::Error;
use crate::actor::{
@ -17,10 +18,12 @@ use crate::admin::{
};
use crate::channel::Receiver;
use crate::delivery::{AddrBuildHasher, AddrMap, Envelope, TickContext, WorkerId};
use crate::stats::{ActorSnapshot, TickTiming, WorkerStats};
use crate::stats::{ActorSnapshot, StatsHook, StatsSnapshotKind, TickTiming, WorkerStats};
use crate::extension::WorkerExtension;
use crate::runtime::RuntimeShared;
const ACTOR_ACTIVITY_INTERVAL: Duration = Duration::from_millis(250);
const ACTOR_CENSUS_INTERVAL: Duration = Duration::from_secs(15);
/// Whether an actor should be skipped during `tick_all`.
pub(crate) fn should_skip_actor(poisoned: bool, stopping: bool, suspended: bool) -> bool {
@ -78,6 +81,10 @@ pub struct Worker {
pub(crate) stats: Arc<WorkerStats>,
/// Reusable scratch buffer for building per-actor snapshots.
snapshot_buf: Vec<ActorSnapshot>,
/// Last activity snapshot handed to the observer.
last_activity_snapshot: Option<Instant>,
/// Last periodic reconciliation census handed to the observer.
last_census_snapshot: Option<Instant>,
/// Per-worker extension (e.g., timer wheel). Created by RuntimeExtension factory.
pub(crate) worker_ext: Option<Box<dyn WorkerExtension>>,
/// True if the previous tick did work — ensures one full tick follows a productive
@ -108,6 +115,8 @@ impl Worker {
admin_rx,
stats,
snapshot_buf: Vec::new(),
last_activity_snapshot: None,
last_census_snapshot: None,
worker_ext: None,
has_backlog: false,
deferred_transfers: VecDeque::new(),
@ -135,9 +144,21 @@ impl Worker {
pub fn try_tick(&mut self) -> bool {
let wid = self.id;
// Fast idle path: skip the entire tick when nothing could have changed.
// Cost: ~3 atomic loads, zero syscalls, zero actor iteration.
// Fast idle path: only the infrequent reconciliation census can be due.
// Between censuses this adds no actor iteration or allocation.
if !self.has_work() {
Self::publish_actor_stats(
self.id,
self.shared.stats_hook.get().map(Arc::as_ref),
&mut self.pool,
&mut self.snapshot_buf,
(
&mut self.last_activity_snapshot,
&mut self.last_census_snapshot,
),
Instant::now(),
false,
);
return false;
}
@ -152,7 +173,6 @@ impl Worker {
config: &shared.config,
extension: shared.extension.get().map(|a| a.as_ref()),
process_output_observer: shared.process_output_observer.get(),
stats_hook: shared.stats_hook.get().map(|a| a.as_ref()),
worker_stats: &self.stats,
num_workers: shared.worker_stats.len(),
worker_id: wid,
@ -277,7 +297,8 @@ impl Worker {
let t5 = Instant::now();
// 8. Publish stats (skip entirely when idle to avoid allocation + mutex)
// 8. Publish cheap aggregate stats. Per-actor telemetry is emitted once
// cleanup has made the worker transition complete.
if did_work {
self.stats
.num_actors
@ -288,11 +309,6 @@ impl Worker {
self.stats
.messages_processed
.fetch_add(processed as u64, Ordering::Relaxed);
if let Some(hook) = tc.stats_hook {
self.pool.mailbox_depths_into(&mut self.snapshot_buf);
hook.on_tick(wid.index(), &self.snapshot_buf);
}
}
let t6 = Instant::now();
@ -323,10 +339,23 @@ impl Worker {
);
}
// 9. Clean up poisoned and stopping actors. Cleanup changes the pool after
// phase 8 published its snapshot, so republish cardinality and mailbox
// state when actors were removed rather than leaving observability stale
// until unrelated work reaches this worker.
// 9. Clean up poisoned and stopping actors before publishing per-actor
if self.pool.has_vital_dirty() {
Self::publish_actor_stats(
self.id,
shared.stats_hook.get().map(Arc::as_ref),
&mut self.pool,
&mut self.snapshot_buf,
(
&mut self.last_activity_snapshot,
&mut self.last_census_snapshot,
),
t6,
did_work,
);
}
// state, so observers see one complete transition.
let cleaned_dead = Self::cleanup_dead_actors(
&mut self.pool,
&mut self.worker_ext,
@ -341,16 +370,64 @@ impl Worker {
self.stats
.total_mailbox_depth
.store(self.pool.total_mailbox_depth(), Ordering::Relaxed);
if let Some(hook) = tc.stats_hook {
self.pool.mailbox_depths_into(&mut self.snapshot_buf);
hook.on_tick(wid.index(), &self.snapshot_buf);
}
}
Self::publish_actor_stats(
self.id,
shared.stats_hook.get().map(Arc::as_ref),
&mut self.pool,
&mut self.snapshot_buf,
(
&mut self.last_activity_snapshot,
&mut self.last_census_snapshot,
),
t6,
did_work,
);
self.has_backlog = did_work;
did_work
}
fn publish_actor_stats(
worker_id: WorkerId,
hook: Option<&dyn StatsHook>,
pool: &mut ActorPool,
snapshot_buf: &mut Vec<ActorSnapshot>,
deadlines: (&mut Option<Instant>, &mut Option<Instant>),
now: Instant,
activity_possible: bool,
) {
let (last_activity_snapshot, last_census_snapshot) = deadlines;
let Some(hook) = hook else {
return;
};
let census = last_census_snapshot
.is_none_or(|last| now.duration_since(last) >= ACTOR_CENSUS_INTERVAL);
let activity = activity_possible
&& last_activity_snapshot
.is_none_or(|last| now.duration_since(last) >= ACTOR_ACTIVITY_INTERVAL);
let vital = pool.take_vital_dirty();
if !census && !activity && !vital {
return;
}
pool.actor_snapshots_into(snapshot_buf, activity);
let kind = match (activity, census) {
(true, true) => StatsSnapshotKind::ACTIVITY_AND_CENSUS,
(true, false) => StatsSnapshotKind::ACTIVITY,
(false, true) => StatsSnapshotKind::CENSUS,
(false, false) => StatsSnapshotKind::VITAL,
};
hook.on_snapshot(worker_id.index(), snapshot_buf, kind);
if activity {
*last_activity_snapshot = Some(now);
}
if census {
*last_census_snapshot = Some(now);
}
}
fn drain_spawns(
pool: &mut ActorPool,
spawn_rx: &Receiver<SpawnRequest>,
@ -738,6 +815,8 @@ struct ActorSlot {
suspended: bool,
last_msg_type: Option<&'static str>,
messages_processed: u64,
/// Highest mailbox depth observed since the previous activity snapshot.
mailbox_max_depth: usize,
/// Per-message-type counters (bounded to 32 entries).
msg_type_counts: HashMap<&'static str, u64>,
/// Address of the actor that spawned this one, or `None` for externally-spawned actors.
@ -751,12 +830,15 @@ struct ActorSlot {
/// Per-worker actor storage. Owns per-actor mailboxes.
pub(crate) struct ActorPool {
actors: AddrMap<ActorSlot>,
/// Population or poison state changed since the previous observer call.
vital_dirty: bool,
}
impl ActorPool {
pub fn new() -> Self {
Self {
actors: HashMap::with_hasher(AddrBuildHasher),
vital_dirty: false,
}
}
@ -772,12 +854,14 @@ impl ActorPool {
suspended: false,
last_msg_type: None,
messages_processed: 0,
mailbox_max_depth: 0,
msg_type_counts: HashMap::new(),
parent_addr: req.parent,
env: req.env,
exit_value: None,
},
);
self.vital_dirty = true;
}
/// Deliver a type-erased message to the actor at `addr`.
@ -806,6 +890,7 @@ impl ActorPool {
}
}
slot.mailbox.push_back(msg);
slot.mailbox_max_depth = slot.mailbox_max_depth.max(slot.mailbox.len());
true
} else {
false
@ -953,6 +1038,7 @@ impl ActorPool {
#[cfg(feature = "tracing")]
tracing::error!(actor_addr = %addr, "actor.on_start_panicked");
slot.poisoned = true;
self.vital_dirty = true;
slot.mailbox.clear();
continue;
}
@ -1035,6 +1121,7 @@ impl ActorPool {
#[cfg(feature = "tracing")]
tracing::error!(actor_addr = %addr, "actor.panicked");
slot.poisoned = true;
self.vital_dirty = true;
slot.mailbox.clear();
break;
}
@ -1124,6 +1211,7 @@ impl ActorPool {
let mut dead = Vec::with_capacity(dead_addrs.len());
for addr in dead_addrs {
if let Some(mut slot) = self.actors.remove(&addr) {
self.vital_dirty = true;
let reason = determine_stop_reason(slot.poisoned, slot.exit_value.is_some());
// Call on_stop for gracefully stopping actors only
debug_assert!(
@ -1154,26 +1242,40 @@ impl ActorPool {
dead
}
/// Fill `out` with per-actor snapshots, reusing the existing allocation.
pub fn mailbox_depths_into(&self, out: &mut Vec<ActorSnapshot>) {
/// Fill `out` with complete per-actor state, reusing its top-level
/// allocation. Activity snapshots begin a new mailbox-maximum window.
pub fn actor_snapshots_into(&mut self, out: &mut Vec<ActorSnapshot>, reset_mailbox_max: bool) {
out.clear();
out.extend(self.actors.iter().map(|(&addr, slot)| {
out.extend(self.actors.iter_mut().map(|(&addr, slot)| {
let mut type_counts: Vec<(&'static str, u64)> =
slot.msg_type_counts.iter().map(|(&k, &v)| (k, v)).collect();
type_counts.sort_by_key(|&(_, count)| Reverse(count));
let metadata = slot.actor.metadata();
ActorSnapshot {
let snapshot = ActorSnapshot {
address: addr,
mailbox_depth: slot.mailbox.len(),
mailbox_max_depth: slot.mailbox_max_depth.max(slot.mailbox.len()),
last_msg_type: slot.last_msg_type,
actor_type: Some(metadata.actor_type_name),
message_type: Some(metadata.message_type_name),
messages_processed: slot.messages_processed,
poisoned: slot.poisoned,
message_type_counts: type_counts,
};
if reset_mailbox_max {
slot.mailbox_max_depth = slot.mailbox.len();
}
snapshot
}));
}
fn has_vital_dirty(&self) -> bool {
self.vital_dirty
}
fn take_vital_dirty(&mut self) -> bool {
std::mem::take(&mut self.vital_dirty)
}
}
#[cfg(test)]

View file

@ -349,6 +349,7 @@ impl ActorInterface for NodeRelayActor {
fn handle(&mut self, _ctx: &Ctx, output: ProcessOutput) {
match output {
ProcessOutput::Stdout(_) | ProcessOutput::Stderr(_) => {}
ProcessOutput::Started { pid } => self.manager.set_pid(self.attempt, pid),
ProcessOutput::Exited { status } => self.manager.set_exited(self.attempt, status),
ProcessOutput::SpawnFailed { error } => {