Skip to main content

Rolling Windows

Sliding-window computations over ordered events. For each event in the source, look at a window of nearby events and compute a reducer.

const rollingAvg = cpu.rolling('5m', { cpu: 'avg' });
// rollingAvg.length === cpu.length; each output's cpu is the
// trailing-5-minute average ending at that event.

Same reducer vocabulary as aggregate — every built-in works, plus custom functions. The difference is per-event output cadence vs. one-per-bucket. For the mental model placing rolling alongside aggregate, reduce, and the streaming variant, see Concepts → Windowing.

Window alignment

What "nearby" means is the alignment. Pick the one that matches the question:

Diagram candidate

ASCII first pass; Excalidraw replacement welcome.

source events ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ●
Timeline ─────────────────────────────────────▶
^ anchor event

trailing (default): [────window────] anchor
centered: [── anchor ──]
leading: anchor [────window────]
  • 'trailing' (default) — "what's the average of what I've seen up to now."
  • 'centered' — "smoothed around now."
  • 'leading' — "what's the average of what's coming next."
cpu.rolling('5m', { cpu: 'avg' }, { alignment: 'centered' });

Opening events — where the window would extend beyond the series' ends — emit undefined under trailing and centered; under leading, trailing events emit undefined instead.

Warm-up gate (minSamples)

By default rolling emits a result for every source event, even when the window holds only one or two samples. For stats whose stability depends on having enough data — rolling stdev, percentiles, the avg/sd pair behind a band chart — those early rows are noisy and can false-flag downstream consumers (anomaly bands collapsing around a tiny early sample). minSamples suppresses output until the window contains at least N source events:

// Don't trust the rolling stats until at least 20 samples are in.
cpu.rolling(
'1m',
{ mean: { from: 'cpu', using: 'avg' }, sd: { from: 'cpu', using: 'stdev' } },
{ minSamples: 20 },
);

Rows below the threshold emit undefined for every reducer column; the output schema and length don't change. Defaults to 0 (no gate). Same option is available on baseline() and outliers() and threads through to their internal rolling pass.

Two output shapes

Pick the one that matches your output cadence:

// Per-event rolling: one output per source event, same key type.
cpu.rolling('5m', { cpu: 'avg' });

// Grid-anchored rolling: one output per Sequence point.
cpu.rolling(Sequence.every('1m'), '5m', { cpu: 'avg' });

The per-event form is the common one. Use the grid form when you want a fixed output cadence independent of where source events land — common for chart sampling.

Mapping shapes

Same two shapes as aggregate:

// Shorthand
cpu.rolling('5m', { cpu: 'avg' });

// Explicit { from, using, kind? } — rename, multi-reducer-per-column
cpu.rolling('5m', {
avg: { from: 'cpu', using: 'avg' },
sd: { from: 'cpu', using: 'stdev' },
peak: { from: 'cpu', using: 'max' },
});

The explicit form is how you get a band chart in one pass — avg and sd from the same source column without two rolling traversals. Anomaly detection ships a baseline helper that's sugar over this exact pattern.

Custom reducers

Same contract as aggregate:

cpu.rolling('5m', {
cpu: (values) => {
const nums = values.filter((v): v is number => typeof v === 'number');
return nums.length === 0
? undefined
: nums.reduce((a, b) => a + b, 0) / nums.length;
},
});

Mix built-in and custom reducers in the same mapping.

Complexity

Most built-in reducers are O(1) per event via incremental add/remove. min / max are O(1) amortized. Median and percentiles are O(N) per event (sorted-array maintenance). Full table on Reducer reference → Rolling window behavior.

tail(duration) — temporal slice

Often paired with rolling for a "current state" readout:

// Trailing 30s, then collapse to scalars.
const recent = series.tail('30s').reduce({
cpu: 'p95',
host: 'unique',
});

tail(duration) is the temporal counterpart to Array.slice(-n): it keeps events whose begin() is strictly greater than lastEvent.begin() - duration. Called with no argument it's the identity (whole series). Composes with every method, not just reduce — plot the last 30s, aggregate just the last hour, etc.

Live: LiveRollingAggregation

The streaming counterpart. Maintains a sliding window incrementally; each source event produces an output event with the rolling aggregate at that point.

import { LiveSeries } from 'pond-ts';

const live = new LiveSeries({ name: 'cpu', schema });

const rolling = live.rolling('5m', { cpu: 'avg' });
// rolling is a LiveRollingAggregation; satisfies LiveSource

rolling.on('event', (event) => {
console.log('rolling avg:', event.get('cpu'));
});

rolling.value(); // Record<string, ColumnValue | undefined>, e.g. { cpu: 0.42 }
// (each value is `undefined` until the window reaches minSamples)

Both mapping shapes work on live the same way they work on batch — the AggregateOutputMap form is how you get multiple stats from one source column in a single rolling pass:

const band = live.rolling('1m', {
mean: { from: 'cpu', using: 'avg' },
sd: { from: 'cpu', using: 'stdev' },
});
band.value(); // { mean, sd } in one pass — no second rolling deque

The same shape works on live.aggregate(seq, ...) and on the synchronised partitioned form (live.partitionBy(col).rolling(window, ..., { trigger: Trigger.clock(seq) })).

Custom-function reducers on live: O(N) per snapshot

Custom (values) => ... reducers work on live rolling and live aggregation (since v0.14.1) but pay an O(window-size) cost at each snapshot() — the function re-runs over every value in the current window each time the accumulator emits. For high-throughput streams prefer a built-in ('avg', 'p95', 'samples', …) or compose multiple built-ins via AggregateOutputMap aliases. See the Custom reducers entry on the reducer reference for the full trade-off.

The output buffer grows unbounded — compose with window() to bound it.

Heads up on late events

Under ordering: 'reorder', a late event becomes a new output event at its insertion point — LiveRollingAggregation does not re-scan historical windows to include it. See Live transforms → Late-event scope for the full picture.

Reporting at a regular cadence: clock triggers

The live-side rolling() doesn't accept a Sequence directly the way batch rolling(seq, window, mapping) does — emission shapes differ (per-event by default for live, per-bucket for batch). When you want to report the rolling state at a regular cadence (e.g. push p95 to a backend every 30 s), set a Trigger.every(duration):

const rolling = live.rolling(
'1m',
{ latency: 'p95' },
{ trigger: Trigger.every('30s') },
);

For the full trigger reference (event / clock / count, semantics, sync partitioned rolling), see Triggering. The Telemetry reporting recipe is the end-to-end worked example.

Multi-window rolling

When a use case needs several trailing windows over the same source — a 1-minute baseline of avg/stdev plus a 200-ms current-tick window of raw samples for anomaly detection, say — pass a record-of-mappings instead of a single (window, mapping) pair:

const fused = live.rolling(
{
'1m': {
cpu_avg: { from: 'cpu', using: 'avg' },
cpu_sd: { from: 'cpu', using: 'stdev' },
},
'200ms': { cpu_samples: { from: 'cpu', using: 'samples' } },
},
{ trigger: Trigger.every('200ms') },
);

fused.on('event', (e) => {
// All four columns from both windows on a single event.
const samples = e.get('cpu_samples') as ReadonlyArray<number>;
const z = samples.map((s) => (s - e.get('cpu_avg')) / e.get('cpu_sd'));
// …
});

This is one rolling primitive that maintains all declared windows in one ingest pass over a single shared deque, then emits one merged event per trigger fire with all windows' columns concatenated into one record.

The naive way before this primitive existed — two separate rolling() calls plus a per-(ts, key) join — works but pays every per-event pipeline cost twice (and N times for N windows). The keyed form does the per-event work once.

Properties

  • One merged output stream. All columns from all windows arrive on a single event. Consumer code is one event handler, not a multi-stream join.
  • Single trigger across all windows. Per-window cadence is not supported — that's what the fusion saves. Users who need different cadences per window fall back to two separate rolling() calls.
  • Time-based windows only. Object keys are duration strings ('1m', '200ms', '5s'); count-based windows (live.rolling(100, ...)) stay on the single-window form and aren't mixable here.
  • Duplicate output column names across windows are rejected at construction.
  • Single-window equivalence. live.rolling('1m', m, opts) and live.rolling({ '1m': m }, opts) are semantically identical — pinned by tests so the unification is incomplete the moment it diverges.

Per-window options

When one window needs different options from the rest (minSamples, e.g.), the value form switches from a bare mapping to an elaborated wrapper:

live.rolling(
{
'1m': { cpu_avg: { from: 'cpu', using: 'avg' } },
'200ms': {
mapping: { cpu_samples: { from: 'cpu', using: 'samples' } },
minSamples: 5, // per-window override
},
},
{ trigger, minSamples: 2 }, // top-level default
);

Top-level options apply as the default across all windows; per-window elaborated minSamples overrides for that window.

Partitioned variant

The same form composes with partitionBy(...) and works through chained pipelines:

const fused = live
.partitionBy('host')
.fill({ cpu: 'hold' })
.rolling(
{
'1m': { cpu_avg: { from: 'cpu', using: 'avg' } },
'200ms': { cpu_samples: { from: 'cpu', using: 'samples' } },
},
{ trigger: Trigger.every('200ms') },
);
// One merged event per partition per boundary; partition column
// auto-injected at the front of the schema.

On the partitioned form, clock trigger is required — synced cross-partition emission needs a single shared boundary detector.

Performance

The architectural argument: per-event hops run once vs N times in N separate rollings. Bench numbers from packages/core/scripts/perf-fused-rolling.mjs:

WindowsSeparate (ms)Multi-window (ms)Wall deltaHeap delta
2152.91102.91−33%−33%
3186.6379.89−57%−54%
4245.42107.51−56%−58%
5279.79118.90−58%−66%

100k events × 100 hosts at each N. Multi-window stays roughly constant; separate scales linearly. At N=5, multi-window is 2.4× faster and uses 34% of the heap. The win compounds — fused pipeline overhead is O(1) in the number of windows; per-window reducer state remains O(N) (which separate also pays).

When to use multi-window vs separate rollings

  • Multi-window when several windows naturally emit together — baseline + current tick, day + hour summaries, etc. The shared ingest pass is the win, and the merged output is the ergonomic win.
  • Separate rollings when the windows have different cadences (one fires every 30s, the other every 200ms), or when you only need one of the windows in a given consumer (you're not paying for the unused one's reducer state).

See Windowing → Multi-window rolling (live) for the conceptual framing.