LiveSeries
LiveSeries is a mutable, append-optimized buffer that shares the same
schema type system as TimeSeries.
Use it when data arrives incrementally — from WebSockets, message queues,
polling loops, or any streaming source — and you need to maintain a
rolling window of recent events.
LiveSeries is a bounded in-order buffer with an opt-in grace
window for moderate reordering at ingest. It's not a full streaming
engine like Apache Beam or Apache Flink: no distributed execution,
engine-managed watermarks, processing-time timers, checkpointed
state, late panes, retractions, or exactly-once/replay guarantees.
pond-ts instead uses a simpler "data is the clock, ordered ingest
with bounded reorder tolerance" model. For the specifics of what
that does and doesn't cover once data leaves the buffer, see
Live Transforms → Late-event scope.
If you're coming from pandas: there's no direct equivalent — pandas
works on already-materialized DataFrame / Series objects rather
than a live source with subscribers. The closest analogue is a
bounded deque you periodically .toTimeSeries() on and run batch
methods against: aggregate() for .resample().agg(), align() for
.asfreq() / fill / interpolation, rolling() for .rolling().agg(),
and groupBy() for .groupby().
If you're coming from PondJS: there is no one-class equivalent to
LiveSeries. PondJS split live work across Stream for unbounded
input, Pipeline for streaming or batch processing, Aggregator for
windowed/grouped aggregation inside a pipeline, and
Collection / TimeSeries for event containers. LiveSeries is the
pond-ts in-process push target and retained live source.
| PondJS | pond-ts |
|---|---|
Stream + Pipeline().from(stream) | LiveSeries.push(...) and live subscriptions |
Pipeline().windowBy(...).aggregate(...) | live.aggregate(...) → LiveAggregation |
| pipeline processors and output callbacks | method chain on LiveSeries / LiveView |
Collection / TimeSeries event containers | TimeSeries; live.toTimeSeries() snapshots |
| source- or pipeline-managed history | retention: { maxEvents / maxAge } on source |
Creating a LiveSeries
- TypeScript
- Rust
import { LiveSeries } from 'pond-ts';
const schema = [
{ name: 'time', kind: 'time' },
{ name: 'cpu', kind: 'number' },
{ name: 'host', kind: 'string' },
] as const;
const live = new LiveSeries({
name: 'cpu-metrics',
schema,
});
use pond_rs::live::LiveSeries;
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let live = LiveSeries::new(
"cpu-metrics",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("cpu", ColumnKind::Number),
ColumnDef::optional("host", ColumnKind::String),
],
)?;
assert_eq!(live.name(), "cpu-metrics");
assert!(live.is_empty());
Ok(())
}
Each column is a ColumnDef: required or optional (gaps allowed).
The schema is identical to what you'd pass to TimeSeries. All the same column
kinds and required: false options work here.
Pushing events
- TypeScript
- Rust
live.push([Date.now(), 0.42, 'api-1']);
live.push([Date.now(), 0.51, 'api-1'], [Date.now(), 0.38, 'api-2']);
use pond_rs::live::LiveSeries;
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu-metrics",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("cpu", ColumnKind::Number),
ColumnDef::optional("host", ColumnKind::String),
],
)?;
// One event: the time in epoch ms, then one cell per value column.
live.push(1_000, [Some(0.42.into()), Some("api-1".into())])?;
// Several at once.
live.push_many([
(2_000, [Some(0.51.into()), Some("api-1".into())]),
(3_000, [Some(0.38.into()), Some("api-2".into())]),
])?;
assert_eq!(live.len(), 3);
// Text in a number column is refused, and nothing is stored.
assert!(
live.push(4_000, [Some("high".into()), Some("api-1".into())])
.is_err()
);
assert_eq!(live.len(), 3);
Ok(())
}
push takes one event and push_many several. A refused batch stores none of its rows.
push() accepts one or more rows. Each row is validated against the schema on
arrival — invalid rows throw a ValidationError. Events must arrive in
timestamp order by default (see ordering modes to change
this).
Pushing columns: pushSeries
When data already arrives as columns (one typed array per column, off a
socket or from a worker), don't turn it into rows. Build a batch with
TimeSeries.fromColumns and push the batch:
- TypeScript
- Rust
import { LiveSeries, TimeSeries } from 'pond-ts';
const schema = [
{ name: 'time', kind: 'time' },
{ name: 'price', kind: 'number' },
{ name: 'size', kind: 'number' },
] as const;
const live = new LiveSeries({
name: 'ticks',
schema,
retention: { maxEvents: 100_000 },
});
// One message: a Float64Array per column, the same length each.
function onMessage(msg: {
time: Float64Array;
price: Float64Array;
size: Float64Array;
}) {
live.pushSeries(
TimeSeries.fromColumns({ name: 'ticks', schema, columns: msg }),
);
}
use pond_rs::live::LiveSeries;
use pond_rs::{Column, ColumnDef, ColumnKind, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"ticks",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::required("price", ColumnKind::Number),
ColumnDef::required("size", ColumnKind::Number),
],
)?;
let batch = TimeSeries::new(
"ticks",
vec![0, 1_000, 2_000],
vec![
Column::required("price", [10.0, 10.5, 10.25]),
Column::required("size", [3.0, 1.0, 2.0]),
],
)?;
live.push_series(&batch)?;
assert_eq!(live.len(), 3);
Ok(())
}
push_series follows the same rules as pond-ts's pushSeries, except on a
series with partitions: it refuses a batch holding a row for an undeclared
group before storing anything, where pond-ts stores the batch and then
throws.
pushSeries follows pushMany's rules for the batch's rows: the same
ordering, retention and listeners, the same refusals, and a refused batch
stores nothing. (One exception, which pushMany shares: on a default series
with partitionBy, a partition error, such as a row for a group the
partitions don't declare, is thrown after the batch is stored.) The batch
must be keyed the same way as the live series and have exactly its value
columns, no more, each of the same kind. Columns are matched by name, in any
order; in TypeScript the parameter's type already asks for the live series'
schema, so that matters for a batch whose schema is only known at run time.
A gap in a column the live schema requires is refused, naming the first such
row, even if the batch's own schema called the column optional.
It is faster only on a default series (ordering: 'strict', a time
key). There the batch's columns are stored as they are: no row tuples, no
check per cell, no event object per row. Anything listening for single
events or batches brings the event objects back, and with them most of
pushMany's cost; that includes a window(), filter() or other view
built on the series. Under ordering: 'drop' or 'reorder', or with a
timeRange or interval key, the batch goes through pushMany's row path,
so the speed is the same.
100,000 rows of time + two numbers, pushed both ways into a default series
(ms, the median of six runs on one 4-core Linux machine, Node 22,
2026-11-01; scripts/perf-live-push-series.mjs; the pushSeries times
include building each batch with fromColumns; "Faster" is the median of
the six runs' own ratios; most runs' ratios sat within a fifth of the median,
the widest single run 23× for one batch of 100,000):
| Case | pushMany | pushSeries | Faster |
|---|---|---|---|
| Batches of 1,600 rows | 18.1 | 1.8 | 10× |
| One batch of 100,000 | 15.0 | 0.9 | 16× |
| Batches of 1,600, keep the last 10,000 | 15.1 | 1.2 | 13× |
| Batches of 16 | 57 | 23 | 2.4× |
| One row at a time | 389 | 601 | pushMany 1.5× faster |
Batches of 1,600 with a window() view | 69 | 54 | 1.3× |
Batches of 1,600, ordering: 'drop' | 81 | 74 | 1.1× |
Batches of 1,600, plus a text host column | 21.4 | 4.6 | 4.6× |
On a default series the live series holds the batch's Float64Arrays
themselves, not copies (fromColumns adopts them, and pushSeries keeps
what fromColumns built). Don't write to, reuse, pool or transfer a pushed
array while its rows are still in the series. The rows aren't checked again,
so a write changes rows already stored and can leave their times out of
order: lookups such as atOrBefore then give wrong answers and a later
toTimeSeries() throws, while a row you already read with at() keeps its
old value. Sending the array's buffer to a worker with
postMessage(msg, [buffer]), or shrinking a resizable buffer, breaks those
rows: their values read back as missing, or reading them throws. If you reuse
buffers, push buf.slice(0, n); that copy is cheap next to pushMany. A push
made from inside a listener waits until the current push ends and reads the
batch then, so leave its arrays alone until it has run.
Two kinds of batch are copied for you. A batch of 8 rows or fewer is always
copied: for arrays you made for that push, copying costs about what deciding
whether to copy would, and keeps less memory (arrays you push a second time
pay about 1.5 µs a push for the copy). And a batch that would keep much more
memory alive than its own rows need, such as history.slice(-100) of a big
series, is copied too. A slice shares its parent's buffers, and its text
columns share the parent's whole list of distinct strings, so keeping it
would keep all of history alive (asTime({ at: 'center' }) on a slice
still shares the number columns). pushSeries copies a batch whose arrays
sit in buffers more than twice the size its rows need, or whose text column
carries more than twice as many distinct strings as it has rows. In that
second case the copy keeps only the strings its rows use; a copy made for
another reason keeps the list as it is, which then holds at most twice as
many strings as rows. A batch whose columns are views into one buffer about
its own size, like a single worker message holding every column, is kept as
it is.
A non-finite number (NaN, Infinity) is a gap here, as fromColumns
treats it; pushMany refuses one. For one row at a time, keep using
push. If each batch is only a few rows and you make its arrays for that
push, pushMany is faster too: making three small Float64Arrays and a
TimeSeries costs more than it saves. Counting that work, 8 rows a push
took 50–72 ms per 100,000 rows through pushMany and 67–90 through
pushSeries (eight runs each), and 16 rows about the same either way (the
table's batches of 16 come from arrays made beforehand).
Reading events
- TypeScript
- Rust
live.length; // number of events in the buffer
live.first(); // oldest event, or undefined
live.last(); // newest event, or undefined
live.at(0); // event by index
live.at(-1); // negative indices count from the end
use pond_rs::live::LiveSeries;
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu-metrics",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("cpu", ColumnKind::Number),
ColumnDef::optional("host", ColumnKind::String),
],
)?;
live.push_many([
(1_000, [Some(0.42.into()), Some("api-1".into())]),
(2_000, [Some(0.51.into()), Some("api-1".into())]),
(3_000, [Some(0.38.into()), Some("api-2".into())]),
])?;
assert_eq!(live.len(), 3);
assert_eq!(live.first().unwrap().number("cpu")?, Some(0.42));
assert_eq!(live.last().unwrap().string("host")?, Some("api-2"));
assert_eq!(live.at(0).unwrap().begin(), 1_000);
assert_eq!(live.at(-1).unwrap().number("cpu")?, Some(0.38));
Ok(())
}
Events are the same immutable Event objects used by TimeSeries.
Snapshots
Convert the current buffer contents into an immutable TimeSeries for batch
analytics:
- TypeScript
- Rust
const ts = live.toTimeSeries();
// All TimeSeries operations work on the snapshot
const smoothed = ts.rolling('5m', { cpu: 'avg' });
const groups = ts.groupBy('host');
use pond_rs::live::LiveSeries;
use pond_rs::{Agg, ColumnDef, ColumnKind, Reducer};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu-metrics",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("cpu", ColumnKind::Number),
ColumnDef::optional("host", ColumnKind::String),
],
)?;
live.push_many([
(1_000, [Some(0.42.into()), Some("api-1".into())]),
(2_000, [Some(0.51.into()), Some("api-1".into())]),
(3_000, [Some(0.38.into()), Some("api-2".into())]),
])?;
let ts = live.to_time_series()?;
// Every batch operation works on the copy.
let smoothed = ts.rolling("5m", &[Agg::of("cpu", Reducer::Avg)])?;
let cpu = smoothed.column("cpu")?.numbers()?;
assert_eq!(cpu.get(1), Some(0.46499999999999997));
let groups = ts.group_by(&["host"])?;
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].1.len(), 2); // api-1
// Later pushes leave the copy alone.
live.push(4_000, [Some(0.9.into()), Some("api-1".into())])?;
assert_eq!(ts.len(), 3);
Ok(())
}
to_time_series takes no name.
The snapshot is independent — future pushes to the LiveSeries don't affect
it. You can pass a custom name: live.toTimeSeries('snapshot-12:05').
Retention policies
Control how much data the buffer holds. Retention is checked after every
push() — no background timers, no implicit scheduling.
- TypeScript
- Rust
const live = new LiveSeries({
name: 'bounded',
schema,
retention: {
maxEvents: 1000, // keep at most 1000 events
maxAge: '30m', // drop events older than 30 minutes
},
});
use pond_rs::live::{LiveOptions, LiveSeries};
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::with_options(
"bounded",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("cpu", ColumnKind::Number),
ColumnDef::optional("host", ColumnKind::String),
],
LiveOptions::new().max_events(3).max_age("30m"),
)?;
let minute = 60_000;
for t in [0, 1, 2, 3] {
live.push(t * minute, [Some(0.1.into()), Some("a".into())])?;
}
// At most 3 events: the oldest went.
let times: Vec<i64> = live.events().map(|e| e.begin()).collect();
assert_eq!(times, [minute, 2 * minute, 3 * minute]);
// 45 minutes: everything older than 30 minutes before it goes.
live.push(45 * minute, [Some(0.2.into()), Some("a".into())])?;
let times: Vec<i64> = live.events().map(|e| e.begin()).collect();
assert_eq!(times, [45 * minute]);
Ok(())
}
Both policies can be combined. When both apply, the one that evicts the most events wins. Eviction always removes the oldest events first.
maxAge is relative to the latest event's timestamp, not wall-clock time.
This follows the "data is the clock" principle — if no events arrive, nothing
is evicted.
Duration strings support ms, s, m, h, d suffixes, or plain
millisecond numbers.
Ordering modes
By default, LiveSeries expects events in timestamp order and throws on
violations. Two alternative modes handle out-of-order data:
strict (default)
const live = new LiveSeries({ name: 'strict', schema });
live.push([1000, 0.5, 'a']);
live.push([500, 0.3, 'a']); // throws ValidationError
drop
Silently discard late events:
const live = new LiveSeries({
name: 'drop-late',
schema,
ordering: 'drop',
});
live.push([1000, 0.5, 'a']);
live.push([500, 0.3, 'a']); // silently dropped
live.length; // 1
reorder
Insert late events in sorted position:
const live = new LiveSeries({
name: 'reorder',
schema,
ordering: 'reorder',
});
live.push([2000, 0.6, 'a']);
live.push([1000, 0.5, 'a']); // inserted before the 2000 event
live.at(0)?.begin(); // 1000
Add a graceWindow to bound how late an event can be:
- TypeScript
- Rust
const live = new LiveSeries({
name: 'bounded-reorder',
schema,
ordering: 'reorder',
graceWindow: '5s',
});
live.push([10000, 0.5, 'a']);
live.push([8000, 0.4, 'a']); // ok, within 5s
live.push([1000, 0.1, 'a']); // throws, outside grace window
use pond_rs::live::{LiveOptions, LiveSeries, OrderingMode};
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let schema = || {
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("cpu", ColumnKind::Number),
ColumnDef::optional("host", ColumnKind::String),
]
};
// Strict (the default): a late event is an error.
let mut strict = LiveSeries::new("strict", schema())?;
strict.push(1_000, [Some(0.5.into()), Some("a".into())])?;
assert!(
strict
.push(500, [Some(0.3.into()), Some("a".into())])
.is_err()
);
// Drop: skipped quietly, and counted.
let mut drop = LiveSeries::with_options(
"drop-late",
schema(),
LiveOptions::new().ordering(OrderingMode::Drop),
)?;
drop.push(1_000, [Some(0.5.into()), Some("a".into())])?;
drop.push(500, [Some(0.3.into()), Some("a".into())])?;
assert_eq!((drop.len(), drop.stats().rejected), (1, 1));
// Reorder: put in its place in time.
let mut reorder = LiveSeries::with_options(
"reorder",
schema(),
LiveOptions::new().ordering(OrderingMode::Reorder),
)?;
reorder.push(2_000, [Some(0.6.into()), Some("a".into())])?;
reorder.push(1_000, [Some(0.5.into()), Some("a".into())])?;
assert_eq!(reorder.at(0).unwrap().begin(), 1_000);
// Reorder, but no more than 5 seconds late.
let mut bounded = LiveSeries::with_options(
"bounded-reorder",
schema(),
LiveOptions::new()
.ordering(OrderingMode::Reorder)
.grace_window("5s"),
)?;
bounded.push(10_000, [Some(0.5.into()), Some("a".into())])?;
bounded.push(8_000, [Some(0.4.into()), Some("a".into())])?; // within 5 s
assert!(
bounded
.push(1_000, [Some(0.1.into()), Some("a".into())])
.is_err()
);
assert_eq!(bounded.len(), 2);
Ok(())
}
This one program covers all four cases above.
Without graceWindow, reorder accepts any timestamp.
Subscriptions
Subscribe to lifecycle events. All callbacks fire synchronously, inline with
push().
- TypeScript
- Rust
// Per-event: fires once for each event added
const unsub = live.on('event', (event) => {
console.log('new:', event.get('cpu'));
});
// Batch: fires once per push() call with all added events
live.on('batch', (events) => {
console.log(`received ${events.length} events`);
});
// Evict: fires when retention removes events
live.on('evict', (events) => {
console.log(`evicted ${events.length} old events`);
});
use pond_rs::live::{LiveOptions, LiveSeries, Output, OutputKind};
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::with_options(
"cpu-metrics",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("cpu", ColumnKind::Number),
ColumnDef::optional("host", ColumnKind::String),
],
LiveOptions::new().max_events(2),
)?;
// Ask for what you want back from each push.
live.watch(live.root(), OutputKind::Event)?;
live.watch(live.root(), OutputKind::Batch)?;
live.watch(live.root(), OutputKind::Evict)?;
// A label for each output, to show the order.
let describe = |out: &[Output]| -> Vec<String> {
out.iter()
.map(|o| match o {
Output::Event { event, .. } => format!("event {}", event.begin()),
Output::Batch { events, .. } => format!("batch {}", events.len()),
Output::Evict { events, .. } => format!("evict {}", events.len()),
_ => unreachable!(),
})
.collect()
};
let out = live.push(0, [Some(0.4.into()), Some("a".into())])?;
assert_eq!(describe(&out), ["event 0", "batch 1"]);
// Two rows: an event for each, then retention keeps 2 (one evicted),
// then the batch, then the eviction.
let out = live.push_many([
(1_000, [Some(0.5.into()), Some("a".into())]),
(2_000, [Some(0.6.into()), Some("a".into())]),
])?;
assert_eq!(
describe(&out),
["event 1000", "event 2000", "batch 2", "evict 1"]
);
// Stop getting events back.
live.unwatch(live.root(), OutputKind::Event)?;
let out = live.push(3_000, [Some(0.7.into()), Some("a".into())])?;
assert_eq!(describe(&out), ["batch 1", "evict 1"]);
Ok(())
}
pond-rs has no listeners. watch says which outputs you want, and each push returns them as a list, in the order pond-ts would call its listeners. unwatch stops them.
Each on() call returns an unsubscribe function. Call it to stop receiving
notifications:
const unsub = live.on('event', handler);
// later...
unsub();
Columns: each push as a TimeSeries
'columns' fires once per push with just the rows that push added, as a
normal TimeSeries. Read whole columns from it instead of one Event per
row: it suits code that copies values into a chart buffer or writes them to
the wire.
live.on('columns', (batch) => {
const time = batch.keyColumn();
const cpu = batch.column('cpu');
for (let i = 0; i < batch.length; i += 1) {
send(time.beginAt(i), cpu.read(i));
}
});
On a default (strict, time-keyed) series this builds no Event and copies
no row: the batch shares the arrays the series stores, and handing it over
costs well under a microsecond a push. (Partition sub-series gather their
rows into fresh arrays, and a pushSeries batch of 8 rows or fewer is copied
on the way in.) With
ordering: 'reorder' or 'drop' the batch is built from the added events,
sorted by key. A batch never changes once handed out, even after retention
drops its rows from the series (holding one keeps those rows in memory),
unless you write into arrays you handed to pushSeries.
Partition sub-series fire it too.
1M rows pushed 1,000 at a time into a 10,000-row buffer (median of 5,
packages/core/scripts/perf-live-columns.mjs):
| the listener | 'batch' | 'columns' |
|---|---|---|
| (none attached) | 130 ms | 130 ms |
| sums one column | 320 ms | 141 ms |
| copies one column into a buffer | 356 ms | 142 ms |
| writes every row as JSON | 446 ms | 251 ms |
pond-rs has no 'columns' output yet.
Callback ordering
Within a single push() call:
- event — fires per event, inline with insertion
- Retention runs (may evict old events)
- batch — fires once with all events that were added
- columns — fires once with the added rows as a
TimeSeries - evict — fires if retention removed events
clear() fires only the evict subscription.
Clearing
- TypeScript
- Rust
live.clear();
use pond_rs::live::{LiveSeries, Output, OutputKind};
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu-metrics",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("cpu", ColumnKind::Number),
ColumnDef::optional("host", ColumnKind::String),
],
)?;
live.watch(live.root(), OutputKind::Evict)?;
live.push(0, [Some(0.4.into()), Some("a".into())])?;
live.push(1_000, [Some(0.5.into()), Some("a".into())])?;
let out = live.clear();
let [Output::Evict { events, .. }] = &out[..] else {
panic!()
};
assert_eq!(events.len(), 2);
assert!(live.is_empty());
live.push(2_000, [Some(0.6.into()), Some("a".into())])?; // ready for more
Ok(())
}
Removes all events and fires the evict subscription with the removed events.
After clearing, the buffer is empty and ready for new pushes.
Typical patterns
WebSocket ingestion with rolling window
const live = new LiveSeries({
name: 'ws-metrics',
schema,
retention: { maxAge: '1h' },
});
ws.on('message', (msg) => {
const { timestamp, cpu, host } = JSON.parse(msg);
live.push([timestamp, cpu, host]);
});
// Periodic snapshot for rendering
setInterval(() => {
const ts = live.toTimeSeries();
renderChart(ts.rolling('5m', { cpu: 'avg' }));
}, 1000);
Monitoring with eviction alerts
- TypeScript
- Rust
const live = new LiveSeries({
name: 'monitor',
schema,
retention: { maxEvents: 500 },
});
live.on('evict', (evicted) => {
metrics.increment('events.evicted', evicted.length);
});
live.on('batch', (events) => {
for (const e of events) {
const cpu = e.get('cpu'); // number | undefined, narrowed from schema
if (cpu !== undefined && cpu > 0.9) {
alert(`High CPU on ${e.get('host')}`);
}
}
});
use pond_rs::live::{LiveOptions, LiveSeries, Output, OutputKind};
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::with_options(
"monitor",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("cpu", ColumnKind::Number),
ColumnDef::optional("host", ColumnKind::String),
],
LiveOptions::new().max_events(2),
)?;
live.watch(live.root(), OutputKind::Batch)?;
live.watch(live.root(), OutputKind::Evict)?;
let mut evicted = 0;
let mut alerts = Vec::new();
let out = live.push_many([
(0, [Some(0.5.into()), Some("api-1".into())]),
(1_000, [Some(0.95.into()), Some("api-2".into())]),
(2_000, [Some(0.97.into()), Some("api-1".into())]),
])?;
for output in out {
match output {
Output::Evict { events, .. } => evicted += events.len(),
Output::Batch { events, .. } => {
for e in events {
if e.number("cpu")?.is_some_and(|cpu| cpu > 0.9) {
alerts.push(format!("High CPU on {}", e.string("host")?.unwrap()));
}
}
}
_ => {}
}
}
assert_eq!(evicted, 1);
assert_eq!(alerts, ["High CPU on api-2", "High CPU on api-1"]);
Ok(())
}
Tolerating out-of-order delivery
const live = new LiveSeries({
name: 'multi-source',
schema,
ordering: 'reorder',
graceWindow: '10s',
});
// Events from multiple sources may arrive slightly out of order
sourceA.on('data', (row) => live.push(row));
sourceB.on('data', (row) => live.push(row));