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",
|
"ed25519-dalek 2.2.0",
|
||||||
"proptest",
|
"proptest",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
|
"rustc-hash",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"swactor",
|
"swactor",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ is hard-capped at 50.
|
||||||
- `GET /api/frames` — recent raw frame window
|
- `GET /api/frames` — recent raw frame window
|
||||||
- `GET /api/views` — registered view metadata
|
- `GET /api/views` — registered view metadata
|
||||||
- `GET /view/telemetry/live` — generic live explorer over retained and incoming telemetry frames
|
- `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 /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` — 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)
|
- `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 hardwareSource = 'node';
|
||||||
let lastSnapshot = null;
|
let lastSnapshot = null;
|
||||||
let detailTimer = null;
|
let detailTimer = null;
|
||||||
|
let refreshInFlight = false;
|
||||||
// A1: last-rendered HTML per region. A poll that yields identical markup
|
// A1: last-rendered HTML per region. A poll that yields identical markup
|
||||||
// must not swap innerHTML — the swap destroyed hover/selection/presses and
|
// must not swap innerHTML — the swap destroyed hover/selection/presses and
|
||||||
// restarted animations every second even in a fully converged steady state.
|
// restarted animations every second even in a fully converged steady state.
|
||||||
|
|
@ -256,13 +257,18 @@ function pushUrl() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
let data;
|
if (refreshInFlight) return;
|
||||||
|
refreshInFlight = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/view/fleet');
|
const response = await fetch('/api/view/fleet');
|
||||||
data = await response.json();
|
const data = await response.json();
|
||||||
} catch { return; }
|
|
||||||
lastSnapshot = data;
|
lastSnapshot = data;
|
||||||
render();
|
render();
|
||||||
|
} catch {
|
||||||
|
// Keep the last good snapshot while the next scheduled refresh retries.
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
|
@ -681,15 +687,20 @@ function stopDetailPolling() {
|
||||||
|
|
||||||
function startDetailPolling() {
|
function startDetailPolling() {
|
||||||
stopDetailPolling();
|
stopDetailPolling();
|
||||||
|
let pollInFlight = false;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
if (!selectedStream || !selectedActor) return;
|
if (!selectedStream || !selectedActor || pollInFlight) return;
|
||||||
let detail;
|
pollInFlight = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/view/fleet/detail?stream=${encodeURIComponent(selectedStream)}&actor=${encodeURIComponent(selectedActor)}`);
|
const response = await fetch(`/api/view/fleet/detail?stream=${encodeURIComponent(selectedStream)}&actor=${encodeURIComponent(selectedActor)}`);
|
||||||
if (!response.ok) { stopDetailPolling(); return; }
|
if (!response.ok) { stopDetailPolling(); return; }
|
||||||
detail = await response.json();
|
const detail = await response.json();
|
||||||
} catch { return; }
|
|
||||||
renderDossier(detail);
|
renderDossier(detail);
|
||||||
|
} catch {
|
||||||
|
// Keep the current dossier while the next scheduled refresh retries.
|
||||||
|
} finally {
|
||||||
|
pollInFlight = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
poll();
|
poll();
|
||||||
detailTimer = setInterval(poll, POLL_MS);
|
detailTimer = setInterval(poll, POLL_MS);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ use crate::FrameEvent;
|
||||||
use crate::view::DashboardView;
|
use crate::view::DashboardView;
|
||||||
|
|
||||||
const LIVE_EXPLORER_HTML: &str = include_str!("live_explorer_page.html");
|
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 LIVE_TTL: Duration = Duration::from_secs(8);
|
||||||
const STALE_STREAM_CAP: usize = 50;
|
const STALE_STREAM_CAP: usize = 50;
|
||||||
|
|
||||||
|
|
@ -65,12 +67,39 @@ impl LiveTelemetryExplorer {
|
||||||
fn snapshot_at(&self, now: Instant) -> Value {
|
fn snapshot_at(&self, now: Instant) -> Value {
|
||||||
let mut state = self.state.write();
|
let mut state = self.state.write();
|
||||||
prune_stale(&mut state.streams, now);
|
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
|
.streams
|
||||||
.values()
|
.values()
|
||||||
.flat_map(|stream| stream.channels.values())
|
.flat_map(|stream| stream.channels.values())
|
||||||
.flat_map(|frames| frames.iter())
|
.map(|frames| frames.iter().rev())
|
||||||
.collect::<Vec<_>>();
|
.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| {
|
frames.sort_by(|left, right| {
|
||||||
left.stream
|
left.stream
|
||||||
.node
|
.node
|
||||||
|
|
@ -138,6 +167,16 @@ mod tests {
|
||||||
for position in 0..=FRAME_HISTORY_CAP as u64 {
|
for position in 0..=FRAME_HISTORY_CAP as u64 {
|
||||||
ingest(&view, &stream, "busy", position);
|
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 snapshot = view.snapshot_json();
|
||||||
let frames = snapshot["frames"].as_array().expect("frames array");
|
let frames = snapshot["frames"].as_array().expect("frames array");
|
||||||
|
|
@ -151,11 +190,7 @@ mod tests {
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
assert_eq!(quiet.len(), 1, "a quiet channel remains discoverable");
|
assert_eq!(quiet.len(), 1, "a quiet channel remains discoverable");
|
||||||
assert_eq!(busy.len(), FRAME_HISTORY_CAP);
|
assert_eq!(busy.len(), SNAPSHOT_FRAME_CAP - quiet.len());
|
||||||
assert_eq!(
|
|
||||||
busy.first().and_then(|frame| frame["position"].as_u64()),
|
|
||||||
Some(1)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
busy.last().and_then(|frame| frame["position"].as_u64()),
|
busy.last().and_then(|frame| frame["position"].as_u64()),
|
||||||
Some(FRAME_HISTORY_CAP as u64)
|
Some(FRAME_HISTORY_CAP as u64)
|
||||||
|
|
@ -201,6 +236,37 @@ mod tests {
|
||||||
assert!(streams.contains("node-59"));
|
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 {
|
fn test_frame(position: u64) -> Frame {
|
||||||
Frame::new(
|
Frame::new(
|
||||||
ChannelId(position as u32 + 1),
|
ChannelId(position as u32 + 1),
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,8 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
|
||||||
seq: 0,
|
seq: 0,
|
||||||
paintQueued: false,
|
paintQueued: false,
|
||||||
paintHandle: 0,
|
paintHandle: 0,
|
||||||
|
reconciling: false,
|
||||||
|
reconcilePending: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dirty = {
|
const dirty = {
|
||||||
|
|
@ -523,6 +525,11 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
|
||||||
state.messageVersion++;
|
state.messageVersion++;
|
||||||
updateMetrics();
|
updateMetrics();
|
||||||
}
|
}
|
||||||
|
if (state.reconciling) {
|
||||||
|
state.reconcilePending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.reconciling = true;
|
||||||
const messageVersion = state.messageVersion;
|
const messageVersion = state.messageVersion;
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
try {
|
try {
|
||||||
|
|
@ -554,6 +561,12 @@ RISK: terminal cosplay if decoration creeps past data; held by the palette law.
|
||||||
updateMetrics();
|
updateMetrics();
|
||||||
renderDetail();
|
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> {
|
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() {
|
while let Ok(event) = self.rx.try_recv() {
|
||||||
out.push(event);
|
out.push(event);
|
||||||
}
|
}
|
||||||
|
|
@ -137,6 +137,31 @@ struct FanoutReport {
|
||||||
disconnected: bool,
|
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 {
|
struct FanoutState {
|
||||||
next_id: u64,
|
next_id: u64,
|
||||||
subscribers: BTreeMap<SubscriptionId, SubscriberSlot>,
|
subscribers: BTreeMap<SubscriptionId, SubscriberSlot>,
|
||||||
|
|
@ -236,11 +261,12 @@ impl DeliveryFanout {
|
||||||
if events.is_empty() {
|
if events.is_empty() {
|
||||||
return EndpointTick::default();
|
return EndpointTick::default();
|
||||||
}
|
}
|
||||||
|
let drained = events.len();
|
||||||
|
|
||||||
// Snapshot sender handles while holding the subscriber map lock, then
|
// Snapshot sender handles while holding the subscriber map lock, then
|
||||||
// deliver outside the lock so large batches or slow subscribers do not
|
// deliver outside the lock so large batches or slow subscribers do not
|
||||||
// block subscribe/snapshot control-plane operations.
|
// block subscribe/snapshot control-plane operations.
|
||||||
let (targets, subscribers) = {
|
let (mut targets, subscribers) = {
|
||||||
let state = self.state.lock().expect("telemetry fanout poisoned");
|
let state = self.state.lock().expect("telemetry fanout poisoned");
|
||||||
let subscribers = state.subscribers.len();
|
let subscribers = state.subscribers.len();
|
||||||
let targets = state
|
let targets = state
|
||||||
|
|
@ -256,7 +282,7 @@ impl DeliveryFanout {
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
return EndpointTick {
|
return EndpointTick {
|
||||||
drained: events.len(),
|
drained,
|
||||||
subscribers: 0,
|
subscribers: 0,
|
||||||
..EndpointTick::default()
|
..EndpointTick::default()
|
||||||
};
|
};
|
||||||
|
|
@ -264,26 +290,18 @@ impl DeliveryFanout {
|
||||||
|
|
||||||
let mut delivered = 0;
|
let mut delivered = 0;
|
||||||
let mut reports = Vec::new();
|
let mut reports = Vec::new();
|
||||||
|
let last_target = targets.pop().expect("nonempty fanout targets");
|
||||||
for target in targets {
|
for target in targets {
|
||||||
let mut dropped = 0;
|
let (target_delivered, report) = publish_to_target(target, events.iter().cloned());
|
||||||
let mut disconnected = false;
|
delivered += target_delivered;
|
||||||
for event in &events {
|
if let Some(report) = report {
|
||||||
match target.tx.try_send(event.clone()) {
|
reports.push(report);
|
||||||
Ok(()) => delivered += 1,
|
|
||||||
Err(TrySendError::Full(_)) => dropped += 1,
|
|
||||||
Err(TrySendError::Disconnected(_)) => {
|
|
||||||
dropped += 1;
|
|
||||||
disconnected = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
let (target_delivered, report) = publish_to_target(last_target, events);
|
||||||
if dropped > 0 || disconnected {
|
delivered += target_delivered;
|
||||||
reports.push(FanoutReport {
|
if let Some(report) = report {
|
||||||
id: target.id,
|
reports.push(report);
|
||||||
dropped,
|
|
||||||
disconnected,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let dropped_for_subscribers = reports.iter().map(|report| report.dropped).sum::<u64>();
|
let dropped_for_subscribers = reports.iter().map(|report| report.dropped).sum::<u64>();
|
||||||
|
|
@ -302,7 +320,7 @@ impl DeliveryFanout {
|
||||||
}
|
}
|
||||||
|
|
||||||
EndpointTick {
|
EndpointTick {
|
||||||
drained: events.len(),
|
drained,
|
||||||
delivered,
|
delivered,
|
||||||
dropped_for_subscribers: usize::try_from(dropped_for_subscribers).unwrap_or(usize::MAX),
|
dropped_for_subscribers: usize::try_from(dropped_for_subscribers).unwrap_or(usize::MAX),
|
||||||
subscribers,
|
subscribers,
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,9 @@ impl Store {
|
||||||
|
|
||||||
/// The stored stream for a node's life, creating an empty one if needed.
|
/// The stored stream for a node's life, creating an empty one if needed.
|
||||||
pub fn stream_mut(&mut self, id: &StreamId) -> &mut StoredStream {
|
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()
|
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"] }
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
rustc-hash = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
//! per-type encoders/decoders so a type-erased message can be put on the wire
|
//! 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.
|
//! and a [`WireEnvelope`] can be turned back into a concrete message.
|
||||||
|
|
||||||
|
use rustc_hash::FxHashMap;
|
||||||
use std::any::{Any, TypeId};
|
use std::any::{Any, TypeId};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
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,
|
/// [`register_decoder`](Self::register_decoder) (variant-multiplexing encode,
|
||||||
/// fan-in decode), then shared read-only via `Arc`.
|
/// fan-in decode), then shared read-only via `Arc`.
|
||||||
pub struct CodecRegistry {
|
pub struct CodecRegistry {
|
||||||
encoders: HashMap<TypeId, EncodeFn>,
|
encoders: FxHashMap<TypeId, EncodeFn>,
|
||||||
decoders: HashMap<String, DecodeFn>,
|
decoders: HashMap<String, DecodeFn>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,7 +164,7 @@ impl Default for CodecRegistry {
|
||||||
impl CodecRegistry {
|
impl CodecRegistry {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
encoders: HashMap::new(),
|
encoders: FxHashMap::default(),
|
||||||
decoders: HashMap::new(),
|
decoders: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue