Live series
Everything else in these pages works on a batch: a whole series you
already have. A live series takes events as they arrive (from a socket, a
queue, a sensor) and keeps whatever is built on it up to date: a filtered
view, a running per-minute average. It is the Rust version of pond-ts's
LiveSeries, and each push gives the same results as pond-ts, in the same
order (checked against cases pond-ts writes out).
The live layer is complete. It covers the live series itself (pushing, ordering rules, how many events to keep, lookups); the
views filter, map, select, sample and window; the running ones
diff, rate, pct_change, fill and cumulative; aggregate
into fixed-length or calendar buckets; rolling windows and reduce, with
triggers that set when they emit; and partitions, one series per host
(or any column) with views and windows on each.
It lives in pond_rs::live, behind the live cargo feature, which is on
by default.
Making one and pushing events
A live series has a name and a schema: the key column first (time for
moments), then the value columns (numbers, text, true/false or arrays).
push(time, cells) adds one event: the time in epoch milliseconds and one
cell per value column, None for a gap. push_many adds a batch;
push_json takes rows written as pond-ts JSON; push_series takes a batch
TimeSeries (data that already comes in columns: an Arrow chunk, a JSON
read) and is the fastest way in.
- Rust
- JavaScript
use pond_rs::live::LiveSeries;
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("host", ColumnKind::String),
ColumnDef::optional("load", ColumnKind::Number),
],
)?;
live.push(0, [Some("a".into()), Some(0.25.into())])?;
live.push_many([
(1_000, [Some("b".into()), Some(0.5.into())]),
(2_000, [Some("a".into()), None]),
])?;
live.push_json(r#"[[3000, "b", 0.75]]"#)?;
assert_eq!(live.len(), 4);
assert_eq!(live.last().unwrap().number("load")?, Some(0.75));
assert_eq!(live.at(-2).unwrap().number("load")?, None); // the gap
assert_eq!(live.at_or_before(1_500).unwrap().string("host")?, Some("b"));
// A copy of what it holds now, as an ordinary batch series.
let batch = live.to_time_series()?;
assert_eq!(batch.len(), 4);
Ok(())
}
import assert from 'node:assert/strict';
import { LiveSeries } from '@pond-ts/rs';
const live = new LiveSeries({
name: 'cpu',
schema: [
{ name: 'time', kind: 'time' },
{ name: 'host', kind: 'string', required: false },
{ name: 'load', kind: 'number', required: false },
],
});
live.push([0, 'a', 0.25]);
live.pushMany([
[1_000, 'b', 0.5],
[2_000, 'a', undefined],
]);
live.pushJson([[3000, 'b', 0.75]]);
assert.equal(live.length, 4);
assert.equal(live.last().get('load'), 0.75);
assert.equal(live.at(-2).get('load'), undefined); // the gap
// A copy of what it holds now, as an ordinary batch series.
const batch = live.toTimeSeries();
assert.equal(batch.length, 4);
batch.free();
live.free(); // frees every view built on it too
In JavaScript (@pond-ts/rs) the classes and methods are pond-ts's (push, pushMany, pushJson, rolling, aggregate, on, …). free() the root series when done; listeners run after pond-rs has taken the push.
The lookups are the batch ones: at (negative counts from the end),
first / last, bisect, at_or_before / at_or_after,
includes_key, find / some / every, and events() to walk them
all. Events are stored column by column, so each read builds the event
it hands you: at, last, events() and the rest give you your own
copy (a LiveEvent), not a reference into the series. time_range() is the span from the first event to the last,
event_rate() the events per second over it, and stats() the counters
(events taken, evicted and skipped). to_json writes what it holds as
pond-ts JSON, and LiveSeries::from_json starts one from it.
A batch is all or nothing. push_many checks every row first, then
the order of the whole batch; if anything is wrong it refuses the batch
and stores none of it. (pond-ts used to keep the rows before a bad one in
some modes; that was fixed there to match.)
Pushing a batch series. push_series(&series) adds every row of a
TimeSeries as one batch, with the same rules and outputs as push_many
of the same rows. The series must be keyed the same way (a time key for a
time-keyed live series) and have exactly the live series' value columns
by name, in any order, each of the same kind; a gap in a required column
is refused like a bad row, naming the row. When nothing is built on the
series and nothing watches it, the columns are copied straight into its
storage without building an event per row, which is what makes it fast:
one million rows in batches of 1,000 take about half the time of
push_many (and about a third less than pond-ts's pushMany; see Speed
below). pond-ts's
pushSeries takes
the same batch under the same rules, but keeps the batch's arrays instead of
copying them, so it hasn't been timed against push_series. One difference:
on a series with partitions, pond-rs refuses a batch with a row for an
undeclared group before storing anything, where pond-ts stores the batch and
then throws.
use pond_rs::live::LiveSeries;
use pond_rs::{Column, ColumnDef, ColumnKind, Float64Column, StringColumn, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("host", ColumnKind::String),
ColumnDef::optional("load", ColumnKind::Number),
],
)?;
let chunk = TimeSeries::new(
"chunk",
vec![0, 1_000, 2_000],
vec![
Column::optional("load", Float64Column::from_options([Some(0.25), Some(0.5), None])),
Column::optional("host", StringColumn::from_options([Some("a"), Some("b"), Some("a")])),
],
)?;
live.push_series(&chunk)?;
assert_eq!(live.len(), 3);
assert_eq!(live.at(1).unwrap().string("host")?, Some("b"));
Ok(())
}
Late events and how many to keep
LiveSeries::with_options(name, schema, LiveOptions::new()...) sets two
things.
What to do with an event that starts before the latest one
(.ordering(…)):
OrderingMode::Strict, the default: refuse it with an error.OrderingMode::Drop: skip it quietly, and count it instats().rejected.OrderingMode::Reorder: store it in its place in time..grace_window("5s")limits how late it may be; an older one is refused. (To keep every late event but let buckets wait for them, see Per-host buckets over late events.)
How many to keep (oldest go first, after each push):
.max_events(n): at mostnevents..max_age("1h"): only events that start within that long of the latest one. Age is measured from the latest event's time, not a clock, so a series that stops receiving events keeps what it has.
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("load", ColumnKind::Number),
]
};
// Strict: a late event is an error, and nothing changes.
let mut strict = LiveSeries::new("cpu", schema())?;
strict.push(5_000, [Some(1.0.into())])?;
assert!(strict.push(4_000, [Some(2.0.into())]).is_err());
assert_eq!(strict.len(), 1);
// Drop: skipped and counted.
let mut drop = LiveSeries::with_options(
"cpu",
schema(),
LiveOptions::new().ordering(OrderingMode::Drop),
)?;
drop.push(5_000, [Some(1.0.into())])?;
drop.push(4_000, [Some(2.0.into())])?;
assert_eq!((drop.len(), drop.stats().rejected), (1, 1));
// Reorder within 2 seconds, keeping the last 3 events.
let mut reorder = LiveSeries::with_options(
"cpu",
schema(),
LiveOptions::new()
.ordering(OrderingMode::Reorder)
.grace_window("2s")
.max_events(3),
)?;
reorder.push(5_000, [Some(1.0.into())])?;
reorder.push(4_000, [Some(2.0.into())])?; // 1 s late: put in place
assert!(reorder.push(1_000, [Some(3.0.into())]).is_err()); // 4 s late
reorder.push(6_000, [Some(4.0.into())])?;
reorder.push(7_000, [Some(5.0.into())])?; // the 4-second event goes
let times: Vec<i64> = reorder.events().map(|e| e.begin()).collect();
assert_eq!(times, [5_000, 6_000, 7_000]);
Ok(())
}
Building on it: filter and aggregate
live.filter(source, test) makes a view of the events that pass a test;
live.aggregate(source, &Sequence::every("1m")?, &aggs) makes fixed
buckets with one column per Agg, as the batch aggregate does. Both
start from what the source holds already, then keep up as events arrive.
The source is live.root() or another node, so they chain.
The live series owns every node built on it. Building one gives back a
small handle (a Node): copyable, safe to send to another thread, and
tied to no borrow. Read the node through the series that owns it:
live[busy], or live.get(busy)? to get an error instead of a panic for
a handle that was removed.
A view drops the events its source drops (by max_events, max_age or
clear). An aggregation keeps its buckets whatever the source drops.
live[node].len(),.events(),.at(…): the closed buckets, oldest first..has_open_bucket(): whether a bucket is still collecting..closed(): the closed buckets as a batch series;.snapshot(): the closed ones plus the open ones as they stand now.
A bucket closes when an event arrives at or after its end. Under
OrderingMode::Reorder with a grace window, it stays open until the
latest event is a grace window past its end, so late events can still
land in it. Calendar buckets (days in a time zone) are
below.
use pond_rs::live::LiveSeries;
use pond_rs::{Agg, ColumnDef, ColumnKind, Reducer, Sequence};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("host", ColumnKind::String),
ColumnDef::optional("load", ColumnKind::Number),
],
)?;
let busy = live.filter(live.root(), |e| {
e.number("load").ok().flatten() > Some(0.8)
})?;
let per_minute = live.aggregate(
live.root(),
&Sequence::every("1m")?,
&[
Agg::new("load_avg", "load", Reducer::Avg),
Agg::new("events", "load", Reducer::Count),
],
)?;
live.push(0, [Some("a".into()), Some(0.9.into())])?;
live.push(30_000, [Some("b".into()), Some(0.5.into())])?;
assert_eq!(live[busy].len(), 1);
assert_eq!(live[per_minute].len(), 0); // the first minute is still open
assert!(live[per_minute].has_open_bucket());
// An event in the second minute closes the first.
live.push(61_000, [Some("a".into()), Some(0.7.into())])?;
let minute = live[per_minute].at(0).unwrap();
assert_eq!(minute.begin(), 0);
assert_eq!(minute.number("load_avg")?, Some(0.7));
assert_eq!(minute.number("events")?, Some(2.0));
// The closed minute, plus the open one as it stands.
assert_eq!(live[per_minute].closed()?.len(), 1);
assert_eq!(live[per_minute].snapshot()?.len(), 2);
Ok(())
}
Aggregating a text, true/false or array column works with count,
first, last, keep, unique, top<N> and samples. Number
reducers (sum, avg, …) on those columns are an error; pond-ts gives 0
or gaps instead (logged as a pond-ts issue, [PND-KINDREDUCE]).
Calendar buckets and a grace of its own
aggregate also takes calendar buckets:
Sequence::calendar("day", "America/New_York") makes one bucket per day
in that time zone, so the day the clocks move is 23 or 25 hours long, as
in the batch aggregate. Days, weeks, months, quarters and years all
work. While a calendar aggregation exists, the series refuses times
outside the years −9997 to 9997; pond-ts takes any time.
By default an aggregation built on the series waits for the series'
grace window before closing a bucket. aggregate_with(from, &sequence, &aggs, Some("10m")) gives it its own grace instead (Some("0s") closes a
bucket as soon as an event lands past its end), as pond-ts's
new LiveAggregation(source, sequence, mapping, { grace }). An
aggregation built on a view has no grace unless you give it one.
use pond_rs::live::{LiveOptions, LiveSeries, OrderingMode};
use pond_rs::{Agg, ColumnDef, ColumnKind, Reducer, Sequence};
fn main() -> Result<(), pond_rs::PondError> {
let hour = 3_600_000;
// Midnight UTC on 9, 10 and 11 March 2024. New York's clocks moved
// forward on the 10th, so that day there is 23 hours long.
let (mar9, mar10, mar11) = (1_709_942_400_000, 1_710_028_800_000, 1_710_115_200_000);
let mut live = LiveSeries::with_options(
"sales",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("amount", ColumnKind::Number),
],
// Late events up to 2 hours are put in place.
LiveOptions::new().ordering(OrderingMode::Reorder).grace_window("2h"),
)?;
let new_york_day = Sequence::calendar("day", "America/New_York")?;
let total = [Agg::new("total", "amount", Reducer::Sum)];
// Takes the series' 2-hour grace: a day stays open 2 hours past its end.
let daily = live.aggregate(live.root(), &new_york_day, &total)?;
// Its own grace of 0: a day closes as soon as the next one starts.
let daily_now = live.aggregate_with(live.root(), &new_york_day, &total, Some("0s"))?;
live.push(mar9 + 17 * hour, [Some(10.0.into())])?; // noon on the 9th, New York
live.push(mar10 + 16 * hour, [Some(20.0.into())])?; // noon on the 10th
live.push(mar11 + 5 * hour, [Some(30.0.into())])?; // 1 am on the 11th
// 11 pm on the 10th, arriving 2 hours late.
live.push(mar11 + 3 * hour, [Some(5.0.into())])?;
// 4 am on the 11th: now 2 hours past the end of the 10th.
live.push(mar11 + 8 * hour, [Some(1.0.into())])?;
let day = live[daily].at(1).unwrap();
// The 10th runs from 05:00 UTC (midnight EST) to 04:00 UTC the next
// day (midnight EDT): 23 hours.
assert_eq!(day.begin(), mar10 + 5 * hour);
assert_eq!(day.end() - day.begin(), 23 * hour);
assert_eq!(day.number("total")?, Some(25.0)); // the late 5 counted
// With no grace, the 10th had closed before the late event came.
let day = live[daily_now].at(1).unwrap();
assert_eq!(day.number("total")?, Some(20.0));
Ok(())
}
Clock triggers on rolling windows and reduce still take even steps only
(Trigger::every), as in pond-ts.
More views
Every view takes a source (live.root() or another view), starts from
what that source holds, and then keeps up. Chain them freely: a fill
on the root, a rate on the fill, a window on the rate.
Reshaping and trimming:
map(from, |event| cells): new values for each event, one cell per value column in schema order. The time and the columns stay the same.select(from, &["load"]): only the named columns.sample(from, Sample::Stride(n)): everynth event, counted from when the view is built.window(from, "5m"): only events within that long of the newest one.window_rows(from, n): only the newestn.live[view].event_rate()gives events per second over a time window.
use pond_rs::live::LiveSeries;
use pond_rs::{ColumnDef, ColumnKind, Sample};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("load", ColumnKind::Number),
ColumnDef::optional("mem", ColumnKind::Number),
],
)?;
let percent = live.map(live.root(), |e| {
let load = e.number("load").ok().flatten();
vec![load.map(|l| (l * 100.0).into()), e.value("mem").unwrap().cloned()]
})?;
let load_only = live.select(live.root(), &["load"])?;
let every_other = live.sample(live.root(), Sample::Stride(2))?;
let last_2s = live.window(live.root(), "2s")?;
let last_two = live.window_rows(live.root(), 2)?;
for (i, load) in [0.25, 0.5, 0.75, 1.0, 0.5].into_iter().enumerate() {
live.push(i as i64 * 1_000, [Some(load.into()), Some(64.0.into())])?;
}
assert_eq!(live[percent].last().unwrap().number("load")?, Some(50.0));
assert_eq!(live[load_only].schema().len(), 2); // time and load
let sampled: Vec<i64> = live[every_other].events().map(|e| e.begin()).collect();
assert_eq!(sampled, [1_000, 3_000]);
assert_eq!(live[last_2s].len(), 3); // 2 s back from 4 s: from 2 s on
assert_eq!(live[last_2s].event_rate()?, 1.5); // 3 events in 2 seconds
assert_eq!(live[last_two].len(), 2);
Ok(())
}
Running calculations, one event at a time, on the columns you name (other columns pass through):
diff(from, &["bytes"]): the change since the previous event.rate(…): that change per second.pct_change(…): as a fraction of the previous value. The first event gets a gap; the_withforms takeFirstRow::Dropto leave it out.fill(from, &[("bytes", Fill::Hold)]): fill gaps as events arrive, withFill::Hold(the last value seen),Fill::ZeroorFill::Value(…).fill_all(from, Fill::Hold)fills every column.Fill::BfillandFill::Linearneed events that have not arrived yet, so live views refuse them, as pond-ts does.cumulative(from, &[("bytes", Reducer::Sum)]): a running sum,Max,MinorCount. A gap keeps the running value.cumulative_with(from, "bytes", |acc, v| …)runs a fold of your own.
use pond_rs::live::LiveSeries;
use pond_rs::{ColumnDef, ColumnKind, Fill, FirstRow, Reducer, Value};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"net",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("bytes", ColumnKind::Number),
],
)?;
// Fill the missing reading, then take bytes per second of the filled values.
let filled = live.fill(live.root(), &[("bytes", Fill::Hold)])?;
let per_second = live.rate(filled, &["bytes"])?;
let changes = live.diff_with(live.root(), &["bytes"], FirstRow::Drop)?;
let total = live.cumulative(live.root(), &[("bytes", Reducer::Sum)])?;
for (t, bytes) in [(0, Some(100.0)), (1_000, None), (2_000, Some(300.0)), (4_000, Some(600.0))] {
live.push(t, [bytes.map(Value::from)])?;
}
let filled: Vec<_> = live[filled].events().map(|e| e.number("bytes").unwrap()).collect();
assert_eq!(filled, [Some(100.0), Some(100.0), Some(300.0), Some(600.0)]);
let rate: Vec<_> = live[per_second].events().map(|e| e.number("bytes").unwrap()).collect();
assert_eq!(rate, [None, Some(0.0), Some(200.0), Some(150.0)]);
// A gap next to either side gives a gap; the first event is left out.
let diffs: Vec<_> = live[changes].events().map(|e| e.number("bytes").unwrap()).collect();
assert_eq!(diffs, [None, None, Some(300.0)]);
let sums: Vec<_> = live[total].events().map(|e| e.number("bytes").unwrap()).collect();
assert_eq!(sums, [Some(100.0), Some(100.0), Some(400.0), Some(1000.0)]);
Ok(())
}
A few things to know:
- Mistakes are refused when the view is built, not ignored: an
unknown column,
difforcumulativeon a column that is not numbers, a fill value of the wrong kind, a stride or row window of 0. pond-ts takes most of these silently and gives gaps. fill_with(…, limit)fills at mostlimitgaps in a row. A live view cannot know how long a run of gaps will be, so it fills the firstlimitand leaves the rest; the batchfill_withleaves a too-long run unfilled.- A
windowtrims itself quietly. It returns noEvictoutput when events fall out of it, and views built on it keep what it trimmed (as in pond-ts). To drop old events everywhere, setmax_eventsormax_ageon the series instead. - Late events under
Reordergo into every view at their place in time. The running views (diff,rate,fill,cumulative,sample) work in arrival order, so a late event's change is against the event that arrived just before it, not the one just before it in time. pond-ts used to throw an error here and now does the same.
Rolling windows and reduce
Two nodes keep running results instead of passing events on:
rolling(from, "30s", &aggs)works out eachAggover a trailing window: the events starting no more than 30 s before the newest one (an event exactly 30 s back still counts). By default it emits one event per source event, keyed at that event.rolling_rows(from, n, &aggs, &options)does the same over the lastnevents instead of a span of time.rolling_windows(from, &[RollingWindow::new("1m", &aggs), …], &options)works out several time windows in one pass over one shared buffer and emits them together, one column perAggacross all windows (output names must differ). It is cheaper than onerollingper window.reduce(from, &aggs)works out eachAggover everythingfromholds now: values are added as events arrive and taken out again asfromdrops them (bymax_events,max_age,clear, or awindowview trimming itself). It emits once at the end of each push, keyed at the push's last event, however many rows the push brought.
rolling takes the series, a view or an aggregation; reduce takes the
series or a view. Nothing can be built on a rolling window or a reduce
(pond-ts has no methods there either). Every reducer the batch aggregate
has works here, including sums of huge numbers kept exact.
RollingOptions sets the rest, for rolling_with, rolling_rows and
rolling_windows:
.min_samples(n): emit gaps until the window holdsnevents (gaps count).RollingWindow::min_samplessets it for one fused window..trigger(…): when to emit.Trigger::event()(the default) for every event;Trigger::count(n)everynth event;Trigger::every("1m")when an event crosses into a new minute, keyed at that minute's start (the first event only sets the minute; a quiet minute emits nothing).reduce_with(from, &aggs, trigger)takes the same triggers..history(…): how many emitted events to keep for reading back:History::All(the default),History::Off, orHistory::Limitwith amax_eventsand/or amax_age. Watchers get every emitted event whatever this says.
Read the current result with live[node].value() (one value per column,
in order, without emitting), the kept events with .events() / .at(i)
(at(-1) is the latest), and counters with .stats(). Watching a rolling
window's OutputKind::Update gives its values after every source event,
even when its trigger did not fire.
use pond_rs::live::{History, LiveSeries, RollingOptions, RollingWindow, Trigger};
use pond_rs::{Agg, ColumnDef, ColumnKind, Reducer};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("load", ColumnKind::Number),
],
)?;
// The average and peak over the last 2 seconds, after every event.
let last_2s = live.rolling(
live.root(),
"2s",
&[
Agg::new("avg", "load", Reducer::Avg),
Agg::new("peak", "load", Reducer::Max),
],
)?;
// The average of the last 3 events, once 3 have arrived, every 2nd
// event, keeping only the latest result.
let last_3 = live.rolling_rows(
live.root(),
3,
&[Agg::new("avg", "load", Reducer::Avg)],
&RollingOptions::new()
.min_samples(3)
.trigger(Trigger::count(2)?)
.history(History::Limit { max_events: Some(1), max_age: None }),
)?;
// Two windows in one pass, emitted together.
let both = live.rolling_windows(
live.root(),
&[
RollingWindow::new("1s", &[Agg::new("avg_1s", "load", Reducer::Avg)]),
RollingWindow::new("4s", &[Agg::new("avg_4s", "load", Reducer::Avg)]),
],
&RollingOptions::new(),
)?;
for (i, load) in [1.0, 2.0, 3.0, 4.0, 5.0].into_iter().enumerate() {
live.push(i as i64 * 1_000, [Some(load.into())])?;
}
// At 4 s the window reaches back to 2 s: 3, 4 and 5.
let latest = live[last_2s].at(-1).unwrap();
assert_eq!(latest.begin(), 4_000);
assert_eq!(latest.number("avg")?, Some(4.0));
assert_eq!(latest.number("peak")?, Some(5.0));
assert_eq!(live[last_2s].len(), 5);
assert_eq!(live[last_2s].value(), [Some(4.0.into()), Some(5.0.into())]);
// Emitted at the 2nd and 4th events (the 2nd still warming up: a gap);
// only the last is kept.
assert_eq!(live[last_3].len(), 1);
let kept = live[last_3].at(0).unwrap();
assert_eq!((kept.begin(), kept.number("avg")?), (3_000, Some(3.0)));
assert_eq!(live[last_3].stats().emissions, 2);
let row = live[both].at(-1).unwrap();
assert_eq!(row.number("avg_1s")?, Some(4.5));
assert_eq!(row.number("avg_4s")?, Some(3.0));
Ok(())
}
A reduce over the series follows what the series keeps, so with
max_events or max_age it is a total over a moving set of events:
use pond_rs::live::{LiveOptions, LiveSeries, Output, OutputKind, Trigger};
use pond_rs::{Agg, ColumnDef, ColumnKind, Reducer};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::with_options(
"orders",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("amount", ColumnKind::Number),
],
LiveOptions::new().max_events(3),
)?;
// Totals over whatever the series holds: its last 3 orders.
let totals = live.reduce(
live.root(),
&[
Agg::new("total", "amount", Reducer::Sum),
Agg::new("orders", "amount", Reducer::Count),
],
)?;
// The same, but only once per minute.
let per_minute = live.reduce_with(
live.root(),
&[Agg::new("total", "amount", Reducer::Sum)],
Trigger::every("1m")?,
)?;
live.watch(totals, OutputKind::Event)?;
// One push of three rows: one emission, keyed at its last row.
let out = live.push_many(vec![
(0, vec![Some(10.0.into())]),
(20_000, vec![Some(20.0.into())]),
(40_000, vec![Some(30.0.into())]),
])?;
assert_eq!(out.len(), 1);
let Output::Event { event, .. } = &out[0] else { panic!() };
assert_eq!(event.begin(), 40_000);
assert_eq!(event.number("total")?, Some(60.0));
// A fourth order pushes the first out of the series, and out of the total.
live.push(65_000, [Some(5.0.into())])?;
let now = live[totals].at(-1).unwrap();
assert_eq!(now.number("total")?, Some(55.0));
assert_eq!(now.number("orders")?, Some(3.0));
// The per-minute reduce emitted once, when 65 s crossed into minute 1,
// keyed at the minute's start.
assert_eq!(live[per_minute].len(), 1);
let minute = live[per_minute].at(0).unwrap();
assert_eq!((minute.begin(), minute.number("total")?), (60_000, Some(55.0)));
Ok(())
}
Partitions: one series per host
Events from many sources often arrive mixed together: readings from a
thousand hosts, trades in many symbols. Running a diff or a rolling
average over the mixed stream compares one host with another.
partition_by(live.root(), "host", &options) splits the series into one
series per value of a column, built when the value first arrives, so each
host's events get their own views and windows.
PartitionOptions: each partition takes the root's ordering, grace window and retention unless you set them (.ordering,.grace_window,.max_events,.max_age; setting either retention limit replaces both)..groups(&["a", "b"])declares the values up front: their partitions are built at once, and a push with any other value is refused.live[hosts]reads the partitioned node:.keys()lists the values in the order their partitions were built,.partition("a")gives that host's series (read it like the root:len,events,at,last…), and.partitions()gives every value with its series.per_partition(hosts, |live, host| …)describes a chain of nodes to build on every partition: the closure gets one partition, builds on it with the usual methods (diff,fill,rolling, …) and returns the last node's id. Nothing is built yet. The closure runs again for every partition, including hosts that appear later, so it must build the same chain each time. It also runs once straight away on an empty series, to learn the output columns and catch mistakes.collect(from, &CollectOptions::new())gathers into one series everything the partitions take, or everything aper_partitionchain emits. It only gathers: what a partition drops stays.apply(hosts, |live, host| …, &options)isper_partitionandcollectin one call.partition_outputs(chain)builds the chain on each partition there is now and returns each value with its output's id;live.node::<LiveView>(id)?turns an id into a handle you can read.
use pond_rs::live::{CollectOptions, LiveSeries, LiveView, PartitionOptions};
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"net",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("host", ColumnKind::String),
ColumnDef::optional("bytes", ColumnKind::Number),
],
)?;
// One series per host, each keeping its last 100 events.
let hosts = live.partition_by(live.root(), "host", &PartitionOptions::new().max_events(100))?;
// The change in bytes since the same host's previous event: a diff
// built on every host's series, including hosts that appear later.
let per_host_diff =
live.per_partition(hosts, |live, host| Ok(live.diff(host, &["bytes"])?.id()))?;
// Every host's diffs gathered back into one series.
let changes = live.collect(per_host_diff, &CollectOptions::new())?;
live.push(0, [Some("a".into()), Some(100.0.into())])?;
live.push(1_000, [Some("b".into()), Some(500.0.into())])?;
live.push(2_000, [Some("a".into()), Some(160.0.into())])?;
live.push(3_000, [Some("b".into()), Some(520.0.into())])?;
assert_eq!(live[hosts].keys(), ["a", "b"]);
let a = live[hosts].partition("a").unwrap();
assert_eq!(live[a].len(), 2);
// Each change is against the same host's previous value, not the
// previous event overall.
let diffs: Vec<_> = live[changes]
.events()
.map(|e| (e.begin(), e.string("host").unwrap().map(String::from), e.number("bytes").unwrap()))
.collect();
assert_eq!(
diffs,
[
(0, Some("a".into()), None),
(1_000, Some("b".into()), None),
(2_000, Some("a".into()), Some(60.0)),
(3_000, Some("b".into()), Some(20.0)),
]
);
// Or build the chain on each host there is now, and read one host's.
let built = live.partition_outputs(per_host_diff)?;
let (key, id) = &built[1];
let b_diff = live.node::<LiveView>(*id)?;
assert_eq!(key, "b");
assert_eq!(live[b_diff].last().unwrap().number("bytes")?, Some(20.0));
Ok(())
}
Every host on one clock. partition_rolling(hosts, "30s", &aggs, &options) keeps a rolling window per host and, each time the clock in
Trigger::every passes a mark, emits one row for every host seen so far,
keyed at the mark: the time, the host, then the columns. A quiet host's
window is first brought up to date, so it reports what its last 30
seconds hold at that moment. partition_rolling_rows and
partition_rolling_windows do the same with a count window or several
time windows. These need a clock trigger; for a window that emits on
every event, build rolling with per_partition.
use pond_rs::live::{LiveSeries, PartitionOptions, RollingOptions, Trigger};
use pond_rs::{Agg, ColumnDef, ColumnKind, Reducer};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("host", ColumnKind::String),
ColumnDef::optional("load", ColumnKind::Number),
],
)?;
let hosts = live.partition_by(live.root(), "host", &PartitionOptions::new())?;
// Each host's average over its last 30 seconds, every host reported
// together each time the clock passes a 10-second mark.
let every_10s = live.partition_rolling(
hosts,
"30s",
&[Agg::new("load_avg", "load", Reducer::Avg)],
&RollingOptions::new().trigger(Trigger::every("10s")?),
)?;
live.push(0, [Some("a".into()), Some(1.0.into())])?;
live.push(5_000, [Some("b".into()), Some(3.0.into())])?;
live.push(10_000, [Some("a".into()), Some(2.0.into())])?; // passes 10 s
// One row per host, keyed at the 10-second mark.
let rows: Vec<_> = live[every_10s]
.events()
.map(|e| {
let host = e.string("host").unwrap().unwrap().to_string();
(e.begin(), host, e.number("load_avg").unwrap())
})
.collect();
assert_eq!(
rows,
[(10_000, "a".to_string(), Some(1.5)), (10_000, "b".to_string(), Some(3.0))]
);
Ok(())
}
Per-host buckets over late events
A common shape: readings from many hosts arrive out of order, and each host needs its own minute buckets that wait a few seconds for stragglers before closing. Three things trip people up here:
- Put the delay on the buckets, not the series. A series' grace window
refuses any event later than it, and the whole push fails. To keep every
late event but hold buckets open for a while, give the series no grace
window and pass the delay to
aggregate_with. - Carry the host into the buckets. A closed bucket has only its time
span and its reducer columns, and the series
applygathers mixes every host. Afirstreducer onhostputs it back. (partition_outputsnames each partition, but only the ones that exist when it is called.) - Buckets from different hosts close out of time order, so the
gathered series needs
Reorder. It takes the partitions' ordering unless you set one.
use pond_rs::live::{CollectOptions, LiveOptions, LiveSeries, OrderingMode, PartitionOptions};
use pond_rs::{Agg, ColumnDef, ColumnKind, Reducer, Sequence};
fn main() -> Result<(), pond_rs::PondError> {
// Readings arrive out of order. No grace window on the series, so no
// late event is refused; the 5-second delay goes on the buckets.
let mut live = LiveSeries::with_options(
"cpu",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("host", ColumnKind::String),
ColumnDef::optional("cpu", ColumnKind::Number),
],
LiveOptions::new().ordering(OrderingMode::Reorder),
)?;
let hosts = live.partition_by(live.root(), "host", &PartitionOptions::new())?;
// Minute buckets per host. A bucket closes once its own host's latest
// event is 5 s past its end. The `host` column carries the host into
// the gathered series, which otherwise mixes every host's buckets.
let minutes = live.apply(
hosts,
|live, host| {
let buckets = live.aggregate_with(
host,
&Sequence::every("1m")?,
&[
Agg::new("host", "host", Reducer::First),
Agg::new("cpu_avg", "cpu", Reducer::Avg),
Agg::new("readings", "cpu", Reducer::Count),
],
Some("5s"),
)?;
Ok(buckets.id())
},
// Buckets from different hosts can close out of time order. The
// gathered series takes the partitions' ordering (Reorder here);
// setting it says so.
&CollectOptions::new().ordering(OrderingMode::Reorder),
)?;
let s = 1_000;
live.push(10 * s, [Some("a".into()), Some(1.0.into())])?;
live.push(20 * s, [Some("b".into()), Some(5.0.into())])?;
live.push(50 * s, [Some("a".into()), Some(3.0.into())])?;
live.push(62 * s, [Some("a".into()), Some(2.0.into())])?;
live.push(59 * s, [Some("a".into()), Some(5.0.into())])?; // late, still in time
live.push(66 * s, [Some("a".into()), Some(1.0.into())])?; // closes a's first minute
live.push(58 * s, [Some("a".into()), Some(100.0.into())])?; // too late: left out
assert_eq!(live[minutes].len(), 1);
live.push(70 * s, [Some("b".into()), Some(7.0.into())])?; // closes b's first minute
let closed: Vec<_> = live[minutes]
.events()
.map(|e| {
let host = e.string("host").unwrap().unwrap().to_string();
(host, e.number("cpu_avg").unwrap(), e.number("readings").unwrap())
})
.collect();
assert_eq!(
closed,
[("a".to_string(), Some(3.0), Some(3.0)), ("b".to_string(), Some(5.0), Some(1.0))]
);
Ok(())
}
What a push returns: outputs
In pond-ts you attach listeners (series.on('event', fn)) and pond-ts
calls them. pond-rs calls none of your code during a push (apart from a
filter's test). Instead, each push returns what it caused, as a list
of Outputs, for the nodes and kinds you asked for with
live.watch(node, kind):
| Kind | Comes from | Means |
|---|---|---|
OutputKind::Event | the series, a view | It took an event |
OutputKind::Event | an aggregation | A bucket closed (the event is the finished bucket) |
OutputKind::Batch | the series | All the events one push stored |
OutputKind::Evict | the series, a view | Events it dropped (retention or clear) |
OutputKind::Bucket | an aggregation | A bucket as it stands after an event joined it |
OutputKind::Update | an aggregation | It took an event, even one too late for any bucket |
OutputKind::Event | a rolling window | It emitted (per its trigger) |
OutputKind::Update | a rolling window | It took an event; carries its values as they stand |
OutputKind::Event | a reduce | It emitted (at the end of a push) |
Nothing is returned for what you don't watch, so an unwatched node costs
nothing extra. The outputs come in the order pond-ts's listeners would
fire: each event travels down the tree (a node's subscribers in the order
they were built), then the series' own retention, Batch and Evict.
output.node() says which node it came from; compare it with
handle.id(). live.unwatch(node, kind) stops it.
use pond_rs::live::{LiveSeries, Output, OutputKind};
use pond_rs::{Agg, ColumnDef, ColumnKind, Reducer, Sequence};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("load", ColumnKind::Number),
],
)?;
let per_minute = live.aggregate(
live.root(),
&Sequence::every("1m")?,
&[Agg::new("load_avg", "load", Reducer::Avg)],
)?;
live.watch(per_minute, OutputKind::Event)?;
live.watch(per_minute, OutputKind::Bucket)?;
// Joining an open bucket: just the bucket as it stands.
let out = live.push(0, [Some(1.0.into())])?;
assert_eq!(out.len(), 1);
assert!(matches!(&out[0], Output::Bucket { event, .. } if event.begin() == 0));
// Starting the next minute: the new bucket first, then the closed one
// (pond-ts's order).
let out = live.push(60_000, [Some(3.0.into())])?;
let kinds: Vec<OutputKind> = out.iter().map(|o| o.kind()).collect();
assert_eq!(kinds, [OutputKind::Bucket, OutputKind::Event]);
let Output::Event { node, event } = &out[1] else { panic!() };
assert_eq!(*node, per_minute.id());
assert_eq!((event.begin(), event.number("load_avg")?), (0, Some(1.0)));
Ok(())
}
Sending outputs somewhere else
Because a push only returns a list, where the outputs go next is up to
you: a channel, a tokio::sync::watch (when only the latest value
matters), a websocket, or a plain function call. A channel takes a few
lines:
use pond_rs::live::{LiveSeries, Output, OutputKind};
use pond_rs::{ColumnDef, ColumnKind};
use std::sync::mpsc;
use std::thread;
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("load", ColumnKind::Number),
],
)?;
let busy = live.filter(live.root(), |e| {
e.number("load").ok().flatten() > Some(0.8)
})?;
live.watch(busy, OutputKind::Event)?;
let (tx, rx) = mpsc::channel::<Output>();
let alerts = thread::spawn(move || {
// Another thread reads the alerts as they come.
rx.iter()
.filter_map(|o| match o {
Output::Event { event, .. } => Some(event.begin()),
_ => None,
})
.collect::<Vec<i64>>()
});
for (t, load) in [(0, 0.5), (1_000, 0.9), (2_000, 0.95), (3_000, 0.1)] {
for output in live.push(t, [Some(load.into())])? {
tx.send(output).unwrap();
}
}
drop(tx); // no more alerts: the reader's loop ends
assert_eq!(alerts.join().unwrap(), [1_000, 2_000]);
Ok(())
}
Threads. A LiveSeries can be moved to another thread. To read it
from several threads, put it behind a lock (Arc<Mutex<LiveSeries>>), or
publish a to_time_series() copy after each push for readers to use.
Removing a node
live.remove(node) takes a node out (pond-ts's dispose). Its handle
then gives an error from live.get(…). Nodes built on it keep what they
hold but get no more events. As in pond-ts, when you remove a view, the
view it was built on is detached too if nothing else uses it (no other
node, nothing watched); it stays readable but stops updating. The series
itself cannot be removed.
use pond_rs::live::LiveSeries;
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"cpu",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("load", ColumnKind::Number),
],
)?;
let busy = live.filter(live.root(), |e| {
e.number("load").ok().flatten() > Some(0.8)
})?;
live.push(0, [Some(0.9.into())])?;
live.remove(busy)?;
assert!(live.get(busy).is_err());
live.push(1_000, [Some(0.95.into())])?; // the series still takes events
assert_eq!(live.len(), 2);
Ok(())
}
What differs from pond-ts
- Outputs instead of listeners, as above. pond-ts calls your listeners during the push; pond-rs returns the same outputs, in the same order, after it.
- Stricter schemas. Exact decimal columns and two columns with the same name are refused; so are number reducers on text columns (pond-ts answers those with 0 or gaps).
keepon an array column compares the arrays' contents, as the batchaggregatedoes. pond-ts compares the array objects, so two equal arrays read as different there ([PND-ARRAYKEY]).- Stricter view arguments, refused when the view is built (see More views).
mapcannot change an event's time, only its values; pond-ts's can.- Reading a rolling window's
value()changes nothing. In pond-ts a read can move the last bit of a laterstdev. pond-rs makes the same reads pond-ts makes during a push, so pushed results still match bit for bit. - A reduce built over events its source already holds emits for them straight away, into its kept events but no push's outputs. pond-ts emits on a microtask just after the build, so a caller that pushes again without yielding gets one emission covering both from pond-ts, and two from pond-rs.
- A clock trigger on a source keyed by ranges or intervals is refused; pond-ts emits time keys under the interval schema there.
- Calendar aggregations take years −9997 to 9997 only, as above.
- Partitions are built on the root only; pond-ts can partition any series.
- A push a partition would refuse is refused whole, before anything is
stored: a value outside the declared
groups, or an event a partition's ordering refuses (as after aclear, which empties the root but not the partitions). pond-ts stores it in the root, delivers the rest and then throws. - Removing a partitioned node also removes the
per_partitionchains described on it. - The host column of
partition_rollingoutput is text (the value as pond-ts writes it," undefined"for a row without one). pond-ts declares it with the source column's type but also fills it with text.
Speed
One million events, pushed one at a time unless noted, timed on one
4-core Linux machine on 2026-10-29 (ms; pond-rs is the middle of three
runs, pond-ts one run with each case in its own process; repeat runs
differ by up to a third). Cases marked 10k keep only the last 10,000
events (max_events).
| Case | pond-rs | pond-ts | Faster |
|---|---|---|---|
| Push only | 331 | 3,944 | 12× |
| Push only, 10k | 273 | 2,846 | 10× |
Reorder, 5% of events up to 1 s late | 348 | 730 | 2.1× |
| Batches of 1,000 | 322 | 131 | pond-ts 2.5× faster |
| A filter and a 1-minute aggregate | 459 | 4,783 | 10× |
| The same, every output watched | 863 | 5,522 | 6.4× |
| The same, 10k | 573 | 4,770 | 8.3× |
diff + rate + pct_change, 10k | 1,227 | 7,269 | 5.9× |
fill (hold) + cumulative, 10k | 735 | 5,949 | 8.1× |
map + select + sample, 10k | 873 | 6,152 | 7.0× |
Two windows, 10k | 529 | 4,895 | 9.2× |
Reorder (5% late), diff + fill + window, 10k | 1,519 | 1,894 | 1.2× |
rolling 30 s: avg + max + stdev, 10k | 550 | 5,410 | 9.8× |
rolling_rows 100: avg, history off, 10k | 488 | 5,165 | 11× |
rolling_windows 1 m + 5 m + 15 m: avg, 10k | 497 | 5,970 | 12× |
reduce: avg + max + count, 10k | 634 | 11,090 | 17× |
aggregate by New York day, 10k | 327 | 4,683 | 14× |
4 hosts, collect, 10k | 852 | 8,532 | 10× |
4 hosts, diff + fill on each, collect, 10k | 2,029 | 10,411 | 5.1× |
4 hosts, partition_rolling_windows 1 m + 5 m, every 1 m, 10k | 895 | 7,274 | 8.1× |
1,000 hosts in batches of 1,000, rolling on each, 10k | 1,250 | 3,238 | 2.6× |
1,000 hosts in batches of 1,000, partition_rolling, 10k | 2,776 | 7,450 | 2.7× |
So one series takes about 3 million events a second in pond-rs, against about 250,000 in pond-ts, and each view or rolling window costs pond-rs a few tenths of a microsecond per event. Read the big ratios with three things in mind:
- pond-ts's one-event push is slow in the default mode. A series in
the default (in order) mode stores events in column chunks built for
batches, and a one-event push builds a whole one-row chunk (about 60% of
the time goes to the garbage collector). In a separate check, one
million in-order events pushed one at a time into a
Reorderseries took pond-ts 0.76–0.8 s rather than 3.9–4.4 s, and the late-events case above takes it 730 ms. Against that path pond-rs is about 2× faster, not 12×. - pond-ts is faster on batches. In batches of 1,000 pond-ts appends
whole column chunks. pond-rs now stores a batch's cells straight from the
rows when nothing reads the series (about 255 ms per million rows after
the 2026-10-29 speed review, from 322), and most of what is left is
freeing the caller's one
Vecper row and the host text, which node does in its garbage collector, off the clock. Reorderwith views is close (1.2× here, 1.2–1.7× across the three pond-rs runs, the noisiest case). pond-ts'sReorderpath skips the one-row chunk above, so itsReordercases are its fastest.
These timings found several pond-ts slowdowns along the way, fixed in
pond-ts at the same time: dropping old events copied the whole buffer on
every push (the 10k diff case took 115 s before the fix); pushing one at
a time got slower as the series grew; and a loop of pushes that never
waited queued one task per push behind a reduce.
Threads and memory allocators
A LiveSeries and everything built on it runs on the thread that pushes.
On a 4-core Linux machine, one thread handled 50 hosts at about 1.0–1.4 million rows a second
(pushes of 1,000 to 100,000 rows, a 1 s rolling average per host, with
collect or partition_rolling; about 1 µs per row in all). Splitting
partitions over threads inside one series was tried and was slower, not
faster: each row's work per host is too small to be worth handing to
another core. For more than one core, run several series, each taking a
share of the hosts; on 1,000 hosts that ran 2.5× faster on 4 threads.
A faster memory allocator helps more than threads. Pushes make and free
many small allocations, and swapping the system allocator (glibc, on Linux) for
mimalloc made the 50-host case
30–45% faster, with no other change (add the crate, then
#[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
in your program).
Memory
A live series stores each column as its own list: a start time per event, numbers as plain 8-byte floats, text and true/false as optional values. Bytes held per event, one million events:
| Columns | Before #842 | Now |
|---|---|---|
| Time + 3 numbers | 249 | 34 |
| Time + text + 2 numbers | 250 | 51 |
| Time + 10 numbers | 585 | 92 |
| Time + text + 2 numbers, plus a filter that keeps everything (series and view together) | 266 | 102 |
One thing to plan for: a view keeps its own copy of the events it holds
rather than sharing the series', so a view that keeps everything costs
about as much memory as the series itself (before #842 such a view cost
about 16 bytes per event, against 51 now). Put window, max_events or
max_age where you can.