perf(runtime): bound telemetry and hot-path work
Cap and fairly distribute live snapshots, serialize dashboard refreshes, reduce telemetry fanout copies, and use a faster codec registry map.
This commit is contained in:
parent
291698c56f
commit
c705c74289
9 changed files with 157 additions and 43 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4349,6 +4349,7 @@ dependencies = [
|
|||
"ed25519-dalek 2.2.0",
|
||||
"proptest",
|
||||
"rand_core 0.6.4",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"swactor",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ is hard-capped at 50.
|
|||
- `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` — 2,000 frames per stream/channel, newest lifetime per node, all live streams plus 50 stale streams
|
||||
- `GET /api/view/telemetry/live` — bounded bootstrap snapshot: up to 500 recent frames and 256 KiB of raw payload, distributed across channels; retention remains 500 frames per stream/channel for live inspection
|
||||
- `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)
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ let rosterSort = { key: 'address', direction: 'asc' };
|
|||
let hardwareSource = 'node';
|
||||
let lastSnapshot = null;
|
||||
let detailTimer = null;
|
||||
let refreshInFlight = false;
|
||||
// A1: last-rendered HTML per region. A poll that yields identical markup
|
||||
// must not swap innerHTML — the swap destroyed hover/selection/presses and
|
||||
// restarted animations every second even in a fully converged steady state.
|
||||
|
|
@ -256,13 +257,18 @@ function pushUrl() {
|
|||
}
|
||||
|
||||
async function refresh() {
|
||||
let data;
|
||||
if (refreshInFlight) return;
|
||||
refreshInFlight = true;
|
||||
try {
|
||||
const response = await fetch('/api/view/fleet');
|
||||
data = await response.json();
|
||||
} catch { return; }
|
||||
lastSnapshot = data;
|
||||
render();
|
||||
const data = await response.json();
|
||||
lastSnapshot = data;
|
||||
render();
|
||||
} catch {
|
||||
// Keep the last good snapshot while the next scheduled refresh retries.
|
||||
} finally {
|
||||
refreshInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
|
|
@ -681,15 +687,20 @@ function stopDetailPolling() {
|
|||
|
||||
function startDetailPolling() {
|
||||
stopDetailPolling();
|
||||
let pollInFlight = false;
|
||||
const poll = async () => {
|
||||
if (!selectedStream || !selectedActor) return;
|
||||
let detail;
|
||||
if (!selectedStream || !selectedActor || pollInFlight) return;
|
||||
pollInFlight = true;
|
||||
try {
|
||||
const response = await fetch(`/api/view/fleet/detail?stream=${encodeURIComponent(selectedStream)}&actor=${encodeURIComponent(selectedActor)}`);
|
||||
if (!response.ok) { stopDetailPolling(); return; }
|
||||
detail = await response.json();
|
||||
} catch { return; }
|
||||
renderDossier(detail);
|
||||
const detail = await response.json();
|
||||
renderDossier(detail);
|
||||
} catch {
|
||||
// Keep the current dossier while the next scheduled refresh retries.
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
}
|
||||
};
|
||||
poll();
|
||||
detailTimer = setInterval(poll, POLL_MS);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ use crate::FrameEvent;
|
|||
use crate::view::DashboardView;
|
||||
|
||||
const LIVE_EXPLORER_HTML: &str = include_str!("live_explorer_page.html");
|
||||
const FRAME_HISTORY_CAP: usize = 2_000;
|
||||
const FRAME_HISTORY_CAP: usize = 500;
|
||||
const SNAPSHOT_FRAME_CAP: usize = 500;
|
||||
const SNAPSHOT_PAYLOAD_BYTE_CAP: usize = 256 * 1024;
|
||||
const LIVE_TTL: Duration = Duration::from_secs(8);
|
||||
const STALE_STREAM_CAP: usize = 50;
|
||||
|
||||
|
|
@ -65,12 +67,39 @@ impl LiveTelemetryExplorer {
|
|||
fn snapshot_at(&self, now: Instant) -> Value {
|
||||
let mut state = self.state.write();
|
||||
prune_stale(&mut state.streams, now);
|
||||
let mut frames = state
|
||||
|
||||
// Take recent frames round-robin across channels so a busy channel
|
||||
// cannot crowd quiet channels out of the bounded bootstrap snapshot.
|
||||
let mut channels = state
|
||||
.streams
|
||||
.values()
|
||||
.flat_map(|stream| stream.channels.values())
|
||||
.flat_map(|frames| frames.iter())
|
||||
.map(|frames| frames.iter().rev())
|
||||
.collect::<Vec<_>>();
|
||||
let mut frames = Vec::with_capacity(SNAPSHOT_FRAME_CAP.min(channels.len()));
|
||||
let mut payload_bytes = 0;
|
||||
'snapshot: loop {
|
||||
let mut found_frame = false;
|
||||
for channel in &mut channels {
|
||||
let Some(frame) = channel.next() else {
|
||||
continue;
|
||||
};
|
||||
found_frame = true;
|
||||
if frame.payload.len() > SNAPSHOT_PAYLOAD_BYTE_CAP - payload_bytes {
|
||||
continue;
|
||||
}
|
||||
payload_bytes += frame.payload.len();
|
||||
frames.push(frame.clone());
|
||||
if frames.len() == SNAPSHOT_FRAME_CAP {
|
||||
break 'snapshot;
|
||||
}
|
||||
}
|
||||
if !found_frame || payload_bytes == SNAPSHOT_PAYLOAD_BYTE_CAP {
|
||||
break;
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
|
||||
frames.sort_by(|left, right| {
|
||||
left.stream
|
||||
.node
|
||||
|
|
@ -138,6 +167,16 @@ mod tests {
|
|||
for position in 0..=FRAME_HISTORY_CAP as u64 {
|
||||
ingest(&view, &stream, "busy", position);
|
||||
}
|
||||
{
|
||||
let state = view.state.read();
|
||||
let busy = &state.streams.values().next().expect("stream").channels["busy"];
|
||||
assert_eq!(busy.len(), FRAME_HISTORY_CAP);
|
||||
assert_eq!(busy.front().map(|frame| frame.position), Some(1));
|
||||
assert_eq!(
|
||||
busy.back().map(|frame| frame.position),
|
||||
Some(FRAME_HISTORY_CAP as u64)
|
||||
);
|
||||
}
|
||||
|
||||
let snapshot = view.snapshot_json();
|
||||
let frames = snapshot["frames"].as_array().expect("frames array");
|
||||
|
|
@ -151,11 +190,7 @@ mod tests {
|
|||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(quiet.len(), 1, "a quiet channel remains discoverable");
|
||||
assert_eq!(busy.len(), FRAME_HISTORY_CAP);
|
||||
assert_eq!(
|
||||
busy.first().and_then(|frame| frame["position"].as_u64()),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(busy.len(), SNAPSHOT_FRAME_CAP - quiet.len());
|
||||
assert_eq!(
|
||||
busy.last().and_then(|frame| frame["position"].as_u64()),
|
||||
Some(FRAME_HISTORY_CAP as u64)
|
||||
|
|
@ -201,6 +236,37 @@ mod tests {
|
|||
assert!(streams.contains("node-59"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_payload_is_bounded() {
|
||||
let view = LiveTelemetryExplorer::default();
|
||||
let stream = StreamId::new(NodeId::new("node-a"), Lifetime(1));
|
||||
let payload = vec![7; SNAPSHOT_PAYLOAD_BYTE_CAP / 2];
|
||||
for (position, channel) in ["first", "second", "third"].into_iter().enumerate() {
|
||||
let event = FrameEvent {
|
||||
stream: crate::StreamEvent {
|
||||
node: stream.node.as_str().to_owned(),
|
||||
life: stream.life.0,
|
||||
origin: None,
|
||||
label: None,
|
||||
},
|
||||
channel: channel.to_owned(),
|
||||
position: position as u64,
|
||||
payload: payload.clone(),
|
||||
};
|
||||
view.ingest_at(&event, Instant::now());
|
||||
}
|
||||
|
||||
let snapshot = view.snapshot_json();
|
||||
let frames = snapshot["frames"].as_array().expect("frames array");
|
||||
let payload_bytes = frames
|
||||
.iter()
|
||||
.map(|frame| frame["payload"].as_array().expect("payload").len())
|
||||
.sum::<usize>();
|
||||
assert!(frames.len() <= SNAPSHOT_FRAME_CAP);
|
||||
assert!(payload_bytes <= SNAPSHOT_PAYLOAD_BYTE_CAP);
|
||||
assert_eq!(frames.len(), 2, "the payload byte cap bounds the snapshot");
|
||||
}
|
||||
|
||||
fn test_frame(position: u64) -> Frame {
|
||||
Frame::new(
|
||||
ChannelId(position as u32 + 1),
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
|
|||
seq: 0,
|
||||
paintQueued: false,
|
||||
paintHandle: 0,
|
||||
reconciling: false,
|
||||
reconcilePending: false,
|
||||
};
|
||||
|
||||
const dirty = {
|
||||
|
|
@ -523,6 +525,11 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
|
|||
state.messageVersion++;
|
||||
updateMetrics();
|
||||
}
|
||||
if (state.reconciling) {
|
||||
state.reconcilePending = true;
|
||||
return;
|
||||
}
|
||||
state.reconciling = true;
|
||||
const messageVersion = state.messageVersion;
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
|
|
@ -554,6 +561,12 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
|
|||
updateMetrics();
|
||||
renderDetail();
|
||||
}
|
||||
} finally {
|
||||
state.reconciling = false;
|
||||
if (state.reconcilePending) {
|
||||
state.reconcilePending = false;
|
||||
reconcileSnapshot({ notice: 'Catching up after concurrent telemetry updates.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ impl TelemetrySubscription {
|
|||
}
|
||||
|
||||
pub fn drain_available(&self) -> Vec<TelemetryEvent> {
|
||||
let mut out = Vec::new();
|
||||
let mut out = Vec::with_capacity(self.rx.len());
|
||||
while let Ok(event) = self.rx.try_recv() {
|
||||
out.push(event);
|
||||
}
|
||||
|
|
@ -137,6 +137,31 @@ struct FanoutReport {
|
|||
disconnected: bool,
|
||||
}
|
||||
|
||||
fn publish_to_target(
|
||||
target: FanoutTarget,
|
||||
events: impl IntoIterator<Item = TelemetryEvent>,
|
||||
) -> (usize, Option<FanoutReport>) {
|
||||
let mut delivered = 0;
|
||||
let mut dropped = 0;
|
||||
let mut disconnected = false;
|
||||
for event in events {
|
||||
match target.tx.try_send(event) {
|
||||
Ok(()) => delivered += 1,
|
||||
Err(TrySendError::Full(_)) => dropped += 1,
|
||||
Err(TrySendError::Disconnected(_)) => {
|
||||
dropped += 1;
|
||||
disconnected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let report = (dropped > 0 || disconnected).then_some(FanoutReport {
|
||||
id: target.id,
|
||||
dropped,
|
||||
disconnected,
|
||||
});
|
||||
(delivered, report)
|
||||
}
|
||||
|
||||
struct FanoutState {
|
||||
next_id: u64,
|
||||
subscribers: BTreeMap<SubscriptionId, SubscriberSlot>,
|
||||
|
|
@ -236,11 +261,12 @@ impl DeliveryFanout {
|
|||
if events.is_empty() {
|
||||
return EndpointTick::default();
|
||||
}
|
||||
let drained = events.len();
|
||||
|
||||
// Snapshot sender handles while holding the subscriber map lock, then
|
||||
// deliver outside the lock so large batches or slow subscribers do not
|
||||
// block subscribe/snapshot control-plane operations.
|
||||
let (targets, subscribers) = {
|
||||
let (mut targets, subscribers) = {
|
||||
let state = self.state.lock().expect("telemetry fanout poisoned");
|
||||
let subscribers = state.subscribers.len();
|
||||
let targets = state
|
||||
|
|
@ -256,7 +282,7 @@ impl DeliveryFanout {
|
|||
|
||||
if targets.is_empty() {
|
||||
return EndpointTick {
|
||||
drained: events.len(),
|
||||
drained,
|
||||
subscribers: 0,
|
||||
..EndpointTick::default()
|
||||
};
|
||||
|
|
@ -264,27 +290,19 @@ impl DeliveryFanout {
|
|||
|
||||
let mut delivered = 0;
|
||||
let mut reports = Vec::new();
|
||||
let last_target = targets.pop().expect("nonempty fanout targets");
|
||||
for target in targets {
|
||||
let mut dropped = 0;
|
||||
let mut disconnected = false;
|
||||
for event in &events {
|
||||
match target.tx.try_send(event.clone()) {
|
||||
Ok(()) => delivered += 1,
|
||||
Err(TrySendError::Full(_)) => dropped += 1,
|
||||
Err(TrySendError::Disconnected(_)) => {
|
||||
dropped += 1;
|
||||
disconnected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if dropped > 0 || disconnected {
|
||||
reports.push(FanoutReport {
|
||||
id: target.id,
|
||||
dropped,
|
||||
disconnected,
|
||||
});
|
||||
let (target_delivered, report) = publish_to_target(target, events.iter().cloned());
|
||||
delivered += target_delivered;
|
||||
if let Some(report) = report {
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
let (target_delivered, report) = publish_to_target(last_target, events);
|
||||
delivered += target_delivered;
|
||||
if let Some(report) = report {
|
||||
reports.push(report);
|
||||
}
|
||||
|
||||
let dropped_for_subscribers = reports.iter().map(|report| report.dropped).sum::<u64>();
|
||||
if !reports.is_empty() {
|
||||
|
|
@ -302,7 +320,7 @@ impl DeliveryFanout {
|
|||
}
|
||||
|
||||
EndpointTick {
|
||||
drained: events.len(),
|
||||
drained,
|
||||
delivered,
|
||||
dropped_for_subscribers: usize::try_from(dropped_for_subscribers).unwrap_or(usize::MAX),
|
||||
subscribers,
|
||||
|
|
|
|||
|
|
@ -143,6 +143,9 @@ impl Store {
|
|||
|
||||
/// The stored stream for a node's life, creating an empty one if needed.
|
||||
pub fn stream_mut(&mut self, id: &StreamId) -> &mut StoredStream {
|
||||
if self.streams.contains_key(id) {
|
||||
return self.streams.get_mut(id).expect("stream checked as present");
|
||||
}
|
||||
self.streams.entry(id.clone()).or_default()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ ed25519-dalek = { version = "2", features = ["std", "rand_core", "serde"] }
|
|||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rustc-hash = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
//! per-type encoders/decoders so a type-erased message can be put on the wire
|
||||
//! and a [`WireEnvelope`] can be turned back into a concrete message.
|
||||
|
||||
use rustc_hash::FxHashMap;
|
||||
use std::any::{Any, TypeId};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -150,7 +151,7 @@ type DecodeFn = Box<dyn Fn(&[u8]) -> Result<Box<dyn Any + Send>, Error> + Send +
|
|||
/// [`register_decoder`](Self::register_decoder) (variant-multiplexing encode,
|
||||
/// fan-in decode), then shared read-only via `Arc`.
|
||||
pub struct CodecRegistry {
|
||||
encoders: HashMap<TypeId, EncodeFn>,
|
||||
encoders: FxHashMap<TypeId, EncodeFn>,
|
||||
decoders: HashMap<String, DecodeFn>,
|
||||
}
|
||||
|
||||
|
|
@ -163,7 +164,7 @@ impl Default for CodecRegistry {
|
|||
impl CodecRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
encoders: HashMap::new(),
|
||||
encoders: FxHashMap::default(),
|
||||
decoders: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue