Skip to main content

Windowing

A window is a subset of events you want to answer a question over. "Average the last minute," "count events in this 5-minute bucket," "what's the running median over the trailing 100 events" — all windowing questions, all answered by reducing a window of events to one or more values.

Pond has five windowing modes. The choice depends on two axes: how the window is defined and when the answer fires.

ModeWindow definitionOutput cadenceOperator
Full windowThe whole seriesOnceseries.reduce(mapping)
Fixed bucketsEach Sequence bucketOnce per bucketseries.aggregate(seq, mapping)
Rolling (batch)Trailing window per eventOnce per source eventseries.rolling(window, mapping)
Rolling (live)Trailing window per eventPer source event OR per triggerlive.rolling(window, mapping, { trigger? })
Multi-window (live)Several trailing windows over one sourcePer trigger, one merged eventlive.rolling({ '1m': m1, '200ms': m2 }, { trigger })

Full window

Reduce the entire series to a single record.

series.reduce({
cpu: 'avg',
hosts: 'unique',
});
// → { cpu: 0.41, hosts: ['api-1', 'api-2', ...] }

One walk over all events; one result. Use when the question is "summarise this whole dataset" — daily reports, post-hoc analysis, test assertions.

reduce returns a plain record, not a series. There's nothing to chain afterwards because there's no temporal axis left.

Fixed buckets

Bucket events onto a Sequence grid and reduce each bucket independently. Each bucket is independent — events in bucket A don't influence bucket B. The output keys are Intervals aligned to the sequence's boundary grid. Use when the question is "summarise per N-second period."

Fixed buckets — batch

series.aggregate(Sequence.every('5m'), {
cpu: 'avg',
cpu_max: { from: 'cpu', using: 'max' },
});
// → new TimeSeries: one Interval-keyed event per 5-minute bucket

One walk over the source, one output series. The bucket boundaries are determined entirely by the sequence — events that fall into the same bucket are reduced together; empty buckets emit per the reducer's empty-bucket policy.

Fixed buckets — live

live.aggregate(Sequence.every('5m'), {
cpu: 'avg',
});
// → LiveAggregation: emits 'close' events as buckets finalize

A live aggregation closes a bucket once the watermark advances past its end — events arrive incrementally, buckets finalise incrementally. The accumulator emits a 'close' event when each bucket finalises and a 'bucket' event continuously while the current bucket is still open. rolling.value()-style continuous reads are also available via .snapshot().

Two streams from one accumulator: the close stream is the durable "this bucket is final" signal (good for backend reporting); the bucket stream is the provisional "in-progress" signal (good for live-display previews of the partial value). Same accumulator, same bucket state, no duplicated work.

Rolling windows (batch)

A trailing window slides over the series; one output event per source event with the window's reduction at that point.

series.rolling('1m', { cpu: 'avg' });
// → new TimeSeries: one event per source event, keyed at the source's key
// value is the avg of the trailing 1-minute window

Window size can be a duration ('1m') or an integer count (100). Output is one-to-one with input events; the window slides by one event each step. Use when the question is "smoothed value at each point" — moving averages, rolling percentiles, anomaly bands.

A grid-form variant emits at sequence points instead of per source event:

series.rolling(Sequence.every('30s'), '5m', { cpu: 'p95' });
// → one event per 30s grid point, value reduces the trailing 5m window

Use the grid form for chart sampling — fixed output cadence independent of where source events land.

Rolling windows (live)

The streaming counterpart. Each source event produces an output, OR output cadence is controlled by a trigger.

import { Trigger } from 'pond-ts';

// Per source event (default — Trigger.event())
live.rolling('1m', { cpu: 'avg' });

// Per sequence boundary crossing
live.rolling('1m', { cpu: 'avg' }, { trigger: Trigger.every('30s') });

// Every N source events
live.rolling('1m', { cpu: 'avg' }, { trigger: Trigger.count(1000) });

The window is maintained incrementally — events add as they arrive, remove as they age out. Per-event cost is O(1) for built-in reducers. The trigger controls when a snapshot fires; the window is always current. See Triggers.

Output stream-shapes match the trigger:

TriggerOutput
event (default)One event per source event
clock / everyOne event per epoch-aligned boundary crossing
count(n)One event per n source events

rolling.value() always reads the current snapshot regardless of trigger — handy for live-display patterns where backend reporting fires at the trigger cadence but the in-app view reads continuously.

Multi-window rolling (live)

When you want several trailing windows over the same source — e.g. a 1-minute baseline of avg/stdev plus a 200-ms current-tick window of raw samples for anomaly detection — 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 z = (s: number) => (s - e.get('cpu_avg')) / e.get('cpu_sd');
// …
});

Properties:

  • One ingest pass. Every source event updates every window's reducer state in one pass over a single shared deque. Two-rolling shapes (which is what users wrote before this primitive existed) pay each per-event pipeline cost twice.
  • One merged output stream. All windows' columns concatenated into one record per trigger fire. Consumer code is one event handler, not a per-(ts, key) join.
  • Single trigger across all windows by design. Per-window cadence is rare; users who need it fall back to two rolling() calls. The simpler API wins for the common case.
  • 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 with time-windows here.
  • Duplicate output column names across windows are rejected at construction. Each output column name is unique across the merged schema.

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 partitionBy(...).rolling({...}, opts), clock trigger is required — synced cross-partition emission needs a single shared boundary detector.

For per-window options, 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
);

See Rolling for the full reference.

Choosing the mode

QuestionReach for
"Summarise everything"reduce
"Per-bucket aggregate"aggregate(seq)
"Smoothed value at each event"rolling(window)
"Smoothed value at fixed grid points"rolling(seq, win)
"Streaming smoothed value, per event"live.rolling(window)
"Streaming snapshot at fixed reporting cadence"live.rolling(window, m, { trigger: Trigger.every(...) })
"Streaming snapshot every N events"live.rolling(window, m, { trigger: Trigger.count(n) })
"Several windows over one source, one merged emit"live.rolling({ '1m': m1, '200ms': m2 }, { trigger })

Multiple stats per window

All five modes accept the AggregateOutputMap shape — multiple named outputs, each reducing the same or different source columns:

series.aggregate(Sequence.every('1m'), {
mean: { from: 'cpu', using: 'avg' },
sd: { from: 'cpu', using: 'stdev' },
n: { from: 'cpu', using: 'count' },
hi: { from: 'cpu', using: 'max' },
});

One walk, four reducers, one output schema with four columns. Pond's runtime executes the four reducers side-by-side over the same window — the per-event cost is O(reducers × 1) for built-in reducers, not O(reducers × N) from running multiple aggregations.

Multiple-stats-per-window vs multi-window. The example above runs four reducers over one window (1-minute buckets). Multi- window rolling runs reducers over several windows (1m + 200ms, say) in one ingest pass. The two compose: each window in a multi-window rolling can carry its own AggregateOutputMap mapping.

Bounded windows on streaming sources

A LiveSeries retains data based on its retention policy ({ maxEvents } or { maxAge }); operators consume the buffer up to that limit. A LiveView.window(duration) adds a tighter event-grid view on top of the buffer for read-side patterns:

const recent = live.window('1m');
recent.length; // number of events in the last 1m
recent.eventRate(); // events-per-second over the window

window is observation-only — it doesn't drive aggregation. Use live.rolling(window, mapping) when you need the window as the reduction range.

What windowing doesn't do

  • Late-event reflow. Events arriving out-of-order land at their insertion point; rolling and aggregation do not recompute historical windows. See Late data.
  • Cross-window state sharing. Two rollings over the same source with different windows are independent — each maintains its own deque and reducer state. (A tap sub-window primitive that shares the parent's deque is parked in PLAN.md.)
  • Per-key (partitioned) windowing. A windowed operator on LiveSeries or TimeSeries runs over the whole series. To window per partition, see Partitioning.

Where this shows up

  • Operator pagesaggregate, reduce, rolling, align.
  • Live transformsLive transforms covers LiveAggregation, LiveRollingAggregation, and the trigger-based emission story; the trigger reference lives at Triggering.
  • Reducers — every windowing operator accepts the same reducer registry. See Reducer reference.
  • Sequences and triggersaggregate and grid-form rolling consume a Sequence; live rolling's emission cadence is shaped by Triggers.