Lattice Grid Buy a licence

developer guide

Live Data Grid Updates at High Rates

A feed that never stops needs a bound. Hold the newest rows with maxRows, or hold a time window with ageBy and maxAge so one busy minute does not push out a quiet hour, and read back what was evicted and what was coalesced.

Developer guideSources and pushdown › Live Data Grid Updates at High Rates

Holding live updates

A pause button for incoming data. Changes are held and merged while paused, applied when play is pressed, and counted throughout, so the coalescing that makes a live grid fast is finally visible.

Pause, play, and the counters

grid.updates.pause();
grid.updates.resume();          // apply everything held
grid.updates.flush();           // apply what is waiting, stay paused

grid.updates.stats();
// { paused, pending, queued, coalesced, coalescedTotal, rows, dropped, flushes, span }

grid.updates.log({ since: Date.now() - 60000 });   // what arrived, in order

Pausing is a button, not a guess. Inferring it from whether the user looks busy sounds friendlier and is wrong in both directions: too broad and a mouse resting on the grid freezes the feed until someone reloads, too narrow and rows jump the instant somebody stops moving in order to read. An explicit control has no heuristic to get wrong and no invisible state to explain.

Merging continues while paused, so a long pause costs one entry per changed row rather than one per update. Forty updates to one row are one row of work when play is pressed, and coalesced is the thirty-nine, which is the number nobody could see before.

Bounding the rows themselves is separate. The log bounds change history; a streaming source also needs to bound row retention, or a grid left up overnight holds every row it was ever sent. Set source.maxRows and the stream becomes a sliding window, dropping the oldest as new ones arrive and reporting how many it let go through evicted on the progress report.

A time window, not just a row count

maxRows is a count window. maxAge is a time window. They are different promises, and a live feed usually wants the second one.

Keep the last five minutes, and show the last five minutes

const grid = createGrid(el, {
  columns,
  source: {
    mode: 'stream',
    open,
    maxAge: 5 * 60 * 1000,   // keep five minutes of rows
    ageBy: 'ts',             // ...aged by this column; omit for arrival time
    maxRows: 20000,          // ...and never more than this many, whichever bites first
  },
});

createChart({
  grid, container, type: 'line', x: 'ts', y: { col: 'value', fn: 'avg' },
  // The x domain is the last five minutes ending *now*, so the chart
  // keeps scrolling left even while the feed is silent.
  axis: { x: { window: { kind: 'time', span: 5 * 60 * 1000 } } },
});

A count window drifts, and it freezes. maxRows equals “the last five minutes” only while the feed rate is steady: a burst silently shrinks the window to two minutes, a quiet spell stretches it to twenty, and the x axis changes span under the reader. Worse, when the feed goes quiet nothing is evicted and the chart stops moving, even though time is still passing - and the silence is usually the thing worth seeing. maxAge is a span of wall clock, so it means the same thing whatever the feed is doing.

An empty reduction is a gap, not a zero. fn: 'avg' above - and sum, mean, min, max, first and last beside it - read as null when a bucket carries no rows to reduce, so a producer that has stopped sending is drawn as a break in the line rather than a value dropping to zero, which would read as a real observation nobody made. count and countValues are the deliberate exception: they are already honest at zero, a tally of rows or of values actually present, so reach for countValues when the reading you actually want is “how many arrived” and zero has to be drawn as zero rather than as a gap.

Two bounds, one eviction path. maxAge and maxRows are independent and compose: both are applied on the same pass and whichever bites first is simply the one that drops rows. Neither is silently ignored. Age eviction reuses the count bound’s machinery outright, so evicted on the progress report and the stream:evicted event carry age evictions exactly as they always carried count evictions - an existing “dropped off the back of the window” readout keeps working with nothing changed.

The span is a bound, not a guillotine. A row lives a little past the span before it goes, and two things add to that. First the eviction slack, ten per cent of the span - exactly the overshoot maxRows already allows on its count - so the row permutation is rebuilt once per block rather than once per arriving row. Second, when the feed is idle, up to one tick of the eviction timer, which runs at a quarter of the span clamped to between 50 ms and one second. The real ceiling is therefore about span × 1.1 + tick, and because the tick has a floor it is proportionally larger the shorter the window: about 10% over at a five-minute window, around 1.25× at ten seconds, and as much as ~1.35× at three. That is the deliberate price of an idle grid that costs no CPU, and it is why the number to reach for is the window you want the reader to see rather than a hard retention limit. The chart’s domain is exact either way - it ends at now - so the extra rows sit off the left edge rather than being drawn.

Which clock: ageBy, or arrival. Given ageBy - a column id, a dotted path, or a function of the row returning a Date, epoch milliseconds or an ISO string - the window follows the data’s own clock, so it means what the producer means. That also inherits the producer’s clock skew: if their clock runs five minutes fast, their rows live five minutes longer than yours. Omit ageBy and rows age from arrival time, when the row reached the source. Arrival time needs no timestamp column and cannot be skewed, but it is not event time - a row delayed in transit is treated as young. A synthetic or metric feed usually wants arrival; a log or event feed usually wants ageBy. A row whose time value cannot be read is never aged out: dropping data because a timestamp was malformed is the worse failure.

Out of order is handled, not reordered. With ageBy the row’s clock need not be monotonic in arrival order, so eviction scans the window rather than walking the head; a late row that is already older than the span is dropped on the same pass it arrived on and counted as evicted, rather than being painted and then withdrawn a moment later. Nothing is re-sorted: a row’s position is still arrival order, only its retention is decided by its time.

The chart axis rolls independently. The axis takes the same WindowSpec vocabulary rolling statistics use - window: { kind: 'time', span } - and its domain ends at now rather than at the newest point, which is what makes the chart keep scrolling with zero new rows. It works with or without maxAge on the source; set both to the same span and the retained data and the drawn domain agree. Only kind: 'time' applies to an axis: a count window over a chart is the source’s maxRows, and kind: 'count' is refused with a warning rather than quietly given a second meaning. The x column has to be continuous and carry wall-clock times - a banded or categorical axis has no domain to roll.

A producer whose clock runs ahead. A reading stamped slightly ahead of the viewer’s clock carries the end of the domain forward with it, so the newest mark is drawn. How far is bounded: a quarter of the span (15 s on a 60 s window). A reading further ahead than that is treated as a producer whose clock is wrong - it does not move the window, it is not drawn, and the chart warns once, naming the column, how many readings were left out and how far ahead they were. Without the bound, one device two minutes fast moved a one-minute window past every other device’s recent readings and the chart drew a single dot. If every reading in the window is that far ahead the chart shows its empty state, with the same warning. chart.data().windowed counts the readings dropped at either edge of the window. The fix for the warning is the producer’s clock, not a wider window.

The other edge: a producer that has simply stopped. The case above is a clock running fast; the opposite is a feed that has gone quiet for longer than the window - every reading is older than the span, so every mark would fall to the left of the domain, off the plot, while the axes and legend keep drawing as if the chart were healthy. Rather than draw that, the chart shows its empty state and warns once per chart instance, naming the span and how old the newest reading actually is, so a dead feed reads as “no data” rather than as a chart that quietly stopped moving. Two charts bound to the same stale column each get their own warning - the key is scoped to the chart, not just the column, so a dashboard of tiled charts sharing one timestamp column does not lose the second warning to the first.

Idle costs nothing. Both halves advance on a plain interval - a quarter of the window, clamped to between 50 ms and one second - and never on an animation frame. The source’s wake returns after a single number comparison unless a row is actually due, and the chart’s wake does nothing at all when the document is hidden or the chart is detached. Both timers are cleared on destroy. Measured over a five-minute window holding 50,000 rows with no feed at all, the window’s CPU cost is inside the run-to-run noise of the same source with no bound set: under 0.04% of one core (bench/idle-window.mjs).

The log keeps the raw sequence, not the merged one. Merging is right for applying a backlog quickly and wrong for looking at what happened, because the intermediate states are exactly what a time scrubber would move between. It survives the flush, pending is what is waiting, the log is what happened, and it is capped, so a grid paused over lunch holds the recent past and reports how much it dropped rather than taking the tab with it.