Cleaning Data
Real ingestion is messy. Timestamps drift, values arrive null,
duplicate rows slip in, the source produces gaps that aggregation
later turns into undefined cells. This page is the place to look
when you need to fix a TimeSeries before downstream transforms
can reason over it.
What's covered:
- Schema first —
required: false— declare optional cells before reaching for any cleaning op. - Filter out bad rows —
.filter()patterns. - Fill the gaps —
.fill()strategies. - Deduplicate —
.dedupe()policies and the multi-entity story. - Regularize to a grid —
.materialize()to emit one row per sequence bucket without picking a fill policy. - End-to-end multi-entity cleaning pipeline — the
full
partitionBy + dedupe + fill(maxGap)chain in one place. - Ingest edges — pointers to where wall-clock timestamps, time zones, and missing values are handled.
Schema first — required: false
Most cleaning starts with knowing which columns can have missing
cells. Declare those columns required: false in your schema —
otherwise the type system narrows their cell values to T (not
T | undefined), and fill/dedupe won't have anything to
operate on:
- TypeScript
- Rust
const schema = [
{ name: 'time', kind: 'time' },
{ name: 'cpu', kind: 'number', required: false }, // ← optional
{ name: 'host', kind: 'string' }, // ← required (always present)
] as const;
use pond_rs::{Column, ColumnDef, ColumnKind, JsonOptions, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
// Built from Rust values: Column::optional allows gaps (None).
let cpu = TimeSeries::new(
"cpu",
vec![0, 1_000],
vec![
Column::optional("cpu", vec![Some(0.5), None]), // optional
Column::required("host", vec!["a", "a"]), // required (always present)
],
)?;
assert_eq!(cpu.column("cpu")?.numbers()?.get(1), None);
// The same schema written out, as a live series or a JSON file takes it.
let schema = [
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::optional("cpu", ColumnKind::Number),
ColumnDef::required("host", ColumnKind::String),
];
assert_eq!(schema.len(), 3);
// From JSON: null is a gap, allowed only where "required" is false.
let ok = r#"{"name": "cpu",
"schema": [{"name": "time", "kind": "time"},
{"name": "cpu", "kind": "number", "required": false}],
"rows": [[0, 0.5], [1000, null]]}"#;
let s = TimeSeries::from_json(ok, &JsonOptions::new())?;
assert_eq!(s.column("cpu")?.numbers()?.get(1), None);
let strict = ok.replace(r#", "required": false"#, "");
assert!(TimeSeries::from_json(&strict, &JsonOptions::new()).is_err());
Ok(())
}
Rust has no type-level narrowing: Column::optional and Column::required set the flag, and pond-rs checks it when the series is built.
When ingesting JSON or CSV-derived rows, missing cells arrive as
null on the wire and become undefined after
TimeSeries.fromJSON(...). (For row-array construction with
new TimeSeries({ rows }), the RowForSchema type currently doesn't
honor required: false — go through fromJSON with null cells
when you need optional values, or see the known limitation in the
0.8.2 changelog.)
Filter out bad rows
filter() is the simplest cleaning tool — keep events that match a
predicate, drop the rest. Same schema in, same schema out, fewer
rows.
- TypeScript
- Rust
// Drop events whose value is missing.
const present = cpu.filter((e) => e.get('cpu') !== undefined);
// Drop obvious sensor errors (negative CPU, value over 100%).
const sane = cpu.filter((e) => {
const v = e.get('cpu');
return v !== undefined && v >= 0 && v <= 1;
});
// Drop events from a known-bad host.
const trusted = events.filter((e) => e.get('host') !== 'staging-1');
use pond_rs::{Column, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let cpu = TimeSeries::new(
"cpu",
vec![0, 1_000, 2_000, 3_000, 4_000],
vec![
Column::optional(
"cpu",
vec![Some(0.5), None, Some(-0.2), Some(1.7), Some(0.8)],
),
Column::required(
"host",
vec!["web-1", "web-1", "staging-1", "web-2", "web-2"],
),
],
)?;
// Drop rows whose value is missing.
let present = cpu.filter(|e| Ok(e.number("cpu")?.is_some()))?;
assert_eq!(present.len(), 4);
// Drop obvious sensor errors (negative CPU, value over 100%).
let sane = cpu.filter(|e| Ok(e.number("cpu")?.is_some_and(|v| (0.0..=1.0).contains(&v))))?;
assert_eq!(sane.key().begin(), &[0, 4_000]);
// Drop rows from a known-bad host.
let trusted = cpu.filter(|e| Ok(e.string("host")? != Some("staging-1")))?;
assert_eq!(trusted.len(), 4);
// Just the bad rows, to inspect.
let bad = cpu.filter(|e| Ok(e.number("cpu")?.is_none_or(|v| !(0.0..=1.0).contains(&v))))?;
assert_eq!(bad.len(), 3);
Ok(())
}
The closure returns a Result, so lookups inside it can use ?.
Filtering early means downstream transforms never see the bad rows. For exploration, also pull just the bad rows into a separate series to inspect:
const bad = cpu.filter((e) => {
const v = e.get('cpu');
return v === undefined || v < 0 || v > 1;
});
console.log(bad.length, 'suspect rows');
Fill the gaps
fill() replaces undefined values that aggregation, alignment, or
upstream sources leave behind. The typical post-aggregate cleanup.
Strategies
'hold'— forward-fill: carry the last known value. Leadingundefinedruns stay unfilled (nothing to carry).'bfill'— backward-fill: pull in the next known value. Trailingundefinedruns stay unfilled.'linear'— time-interpolate between the two nearest known values on either side. Leading and trailingundefinedruns stay unfilled (no extrapolation).'zero'— fill with0. Numeric columns only.
A single strategy applies to every value column:
- TypeScript
- Rust
series.fill('hold');
series.fill('linear');
series.fill('zero');
use pond_rs::{Column, Fill, TimeSeries, Value};
fn main() -> Result<(), pond_rs::PondError> {
let min = 60_000;
let series = TimeSeries::new(
"cpu",
(0..5).map(|i| i * min).collect(),
vec![
Column::optional("cpu", vec![None, Some(0.2), None, Some(0.6), None]),
Column::optional("host", vec![None, Some("a"), None, Some("b"), None]),
],
)?;
let col = |s: &TimeSeries| -> Result<Vec<Option<f64>>, pond_rs::PondError> {
Ok(s.column("cpu")?.numbers()?.iter().collect())
};
// One rule for every column. The leading gap stays: nothing to carry.
let held = series.fill_all(Fill::Hold)?;
assert_eq!(
col(&held)?,
[None, Some(0.2), Some(0.2), Some(0.6), Some(0.6)]
);
let back = series.fill_all(Fill::Bfill)?;
assert_eq!(
col(&back)?,
[Some(0.2), Some(0.2), Some(0.6), Some(0.6), None]
);
// A rule per column; columns not named keep their gaps.
let mixed = series.fill(&[("cpu", Fill::Linear), ("host", Fill::Hold)])?;
assert_eq!(col(&mixed)?, [None, Some(0.2), Some(0.4), Some(0.6), None]);
// Fixed values.
let fixed = series.fill(&[
("cpu", Fill::Value(Value::Number(0.0))),
("host", Fill::Value(Value::String("unknown".into()))),
])?;
assert_eq!(fixed.column("host")?.strings()?.get(0), Some("unknown"));
// Zero fills number columns; text columns keep their gaps.
let zeroed = series.fill_all(Fill::Zero)?;
assert_eq!(
col(&zeroed)?,
[Some(0.0), Some(0.2), Some(0.0), Some(0.6), Some(0.0)]
);
assert_eq!(zeroed.column("host")?.strings()?.get(0), None);
Ok(())
}
fill_all is the one-rule form; fill takes a rule per column. pond-rs refuses an unknown column, Linear on a column that is not numbers, and a fixed value of the wrong kind; pond-ts lets these through.
Per-column mapping targets specific columns; other columns pass
through with their undefineds intact:
series.fill({ cpu: 'linear', host: 'hold' });
Literal fill values (anything that's not a strategy string) set the column to that value:
series.fill({ cpu: 0, host: 'unknown' });
Gap caps — limit (cells) and maxGap (duration)
fill() operates on gaps — runs of consecutive undefined cells
in one column. Both options cap how big a gap can be before fill
gives up:
- TypeScript
- Rust
// Count-based: gap of at most 3 cells fits the cap
series.fill('hold', { limit: 3 });
// Duration-based: gap whose temporal span (prev-known to next-known)
// is at most 5 minutes fits the cap
series.fill('linear', { maxGap: '5m' });
// Both — the more restrictive wins
series.fill('linear', { limit: 3, maxGap: '5m' });
use pond_rs::{Column, Fill, FillLimits, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
// A one-minute gap, then a five-minute outage.
let series = TimeSeries::new(
"cpu",
(0..9).map(|i| i * 60_000).collect(),
vec![Column::optional(
"cpu",
vec![
Some(1.0),
None,
Some(3.0),
Some(10.0),
None,
None,
None,
None,
Some(20.0),
],
)],
)?;
let col = |s: &TimeSeries| -> Result<Vec<Option<f64>>, pond_rs::PondError> {
Ok(s.column("cpu")?.numbers()?.iter().collect())
};
// At most 3 cells: the 4-cell outage is left whole.
let held = series.fill_all_with(Fill::Hold, &FillLimits::none().limit(3))?;
assert_eq!(
col(&held)?,
[
Some(1.0),
Some(1.0),
Some(3.0),
Some(10.0),
None,
None,
None,
None,
Some(20.0)
]
);
// At most 5 minutes from the value before to the value after.
let lined = series.fill_all_with(Fill::Linear, &FillLimits::none().max_gap("5m"))?;
assert_eq!(
col(&lined)?,
[
Some(1.0),
Some(2.0),
Some(3.0),
Some(10.0),
Some(12.0),
Some(14.0),
Some(16.0),
Some(18.0),
Some(20.0)
]
);
// Both: the stricter wins.
let both = series.fill_all_with(Fill::Linear, &FillLimits::none().limit(3).max_gap("5m"))?;
assert_eq!(col(&both)?[4], None);
Ok(())
}
fill_with and fill_all_with take the caps as FillLimits.
All-or-nothing semantics. A gap either fits the caps or it doesn't:
- Gap fits → fill the entire gap with the strategy.
- Gap exceeds either cap → leave the entire gap unfilled.
This is the v0.9.0 default. Earlier versions partially filled
(limit: 3 on a 5-cell gap filled 3, left 2 unfilled). Partial
fill propagated stale 'hold' values past their useful lifetime
and produced misleading 'linear' interpolations across long
outages. All-or-nothing is honest: if the gap is too big to bridge,
leave the unknown unknown.
// Series with one 10-minute outage gap:
const cpu = series.fill('linear', { maxGap: '5m' });
// 10-min gap exceeds 5-min cap → outage remains undefined.
// A naive linear interp would have invented a smooth slope across
// data we don't have.
Typical post-aggregate pipeline
- TypeScript
- Rust
const hourly = series
.aggregate(Sequence.every('1h'), { cpu: 'avg', host: 'last' })
.fill({ cpu: 'linear', host: 'hold' }, { maxGap: '3h' });
use pond_rs::{Agg, Column, Fill, FillLimits, Reducer, Sequence, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let (min, h) = (60_000, 3_600_000);
let series = TimeSeries::new(
"cpu",
vec![0, 10 * min, 2 * h + 5 * min, 7 * h],
vec![
Column::required("cpu", vec![0.2, 0.4, 0.8, 0.5]),
Column::required("host", vec!["a", "a", "a", "a"]),
],
)?;
let hourly = series
.aggregate(
&Sequence::every("1h")?,
&[Agg::of("cpu", Reducer::Avg), Agg::of("host", Reducer::Last)],
)?
.fill_with(
&[("cpu", Fill::Linear), ("host", Fill::Hold)],
&FillLimits::none().max_gap("3h"),
)?;
let cpu: Vec<_> = hourly.column("cpu")?.numbers()?.iter().collect();
// Hour 1 is filled; hours 3 to 6 are a 5-hour gap, left alone.
assert_eq!(
cpu,
[
Some(0.30000000000000004),
Some(0.55),
Some(0.8),
None,
None,
None,
None,
Some(0.5)
]
);
Ok(())
}
Multi-entity series — see partitionBy
fill() walks one chronological event sequence. On a multi-entity
series (events for multiple hosts interleaved by time), neighbors
cross entity boundaries — host-A's missing cell would interpolate
against host-B's value as a "neighbor."
Use partitionBy(col) to scope fill to
within each entity:
- TypeScript
- Rust
series.partitionBy('host').fill({ cpu: 'linear' }).collect();
use pond_rs::{Column, Fill, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let series = TimeSeries::new(
"cpu",
vec![0, 60, 60, 120],
vec![
Column::optional("cpu", vec![Some(0.5), None, Some(0.9), Some(0.7)]),
Column::required("host", vec!["a", "a", "b", "a"]),
],
)?;
// Without partitions, host a's gap is filled from host b's 0.9.
let mixed = series.fill(&[("cpu", Fill::Linear)])?;
assert_eq!(mixed.column("cpu")?.numbers()?.get(1), Some(0.9));
// Per host: a fills between its own 0.5 and 0.7.
let per_host = series
.partition_by(&["host"])?
.fill(&[("cpu", Fill::Linear)])?
.collect()?;
assert_eq!(per_host.column("cpu")?.numbers()?.get(1), Some(0.6));
Ok(())
}
See Concepts → Partitioning for the full hazard discussion.
Deduplicate
dedupe() collapses events that share a key. Real-world ingest
produces duplicates: WebSocket replays, Kafka at-least-once, retried
HTTP fetches, polling overlaps. The default resolution is
last-wins (matches the "newer event supersedes" intuition).
For series carrying multiple entities (host, region, device id),
bare dedupe() will collapse events from different entities at
the same timestamp as if they were duplicates of each other. Always
pair it with partitionBy
on multi-entity series before reaching for the operator.
For single-entity series, the default key is the event's full key
(timestamp for time-keyed series; begin+end for time-range and
interval keys; the labeled value is part of identity for
intervals):
// Single-entity series — collapse same-timestamp duplicates, keep last.
const clean = events.dedupe();
Multi-entity dedupe — partition first
If your series carries multiple entities, you almost always want
(time + entity) as the dedupe key, not time alone. Bare
dedupe() would collapse host=A@t and host=B@t as if they were
duplicates of each other.
Pair it with partitionBy(col):
- TypeScript
- Rust
// Per-host dedupe — same time AND same host is the duplicate key.
const clean = events.partitionBy('host').dedupe({ keep: 'last' }).collect();
use pond_rs::{Column, Keep, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let min = 60_000;
// Hosts a and b both report at minute 1.
let events = TimeSeries::new(
"cpu",
vec![0, min, min, 2 * min],
vec![
Column::required("cpu", vec![0.5, 0.4, 0.9, 0.7]),
Column::required("host", vec!["a", "a", "b", "a"]),
],
)?;
// Bare dedupe treats a and b at minute 1 as duplicates.
assert_eq!(events.dedupe(Keep::Last)?.len(), 3);
// Per host: the same time AND the same host is the duplicate key.
let clean = events
.partition_by(&["host"])?
.dedupe(Keep::Last)?
.collect()?;
assert_eq!(clean.len(), 4);
Ok(())
}
Resolution policies — keep
keep | Behavior |
|---|---|
'last' (default) | Keep the last event at each timestamp. Matches WebSocket replay / "newer wins" intuition. |
'first' | Keep the first event encountered. |
'error' | Throw on the first duplicate seen. Useful when duplicates indicate an upstream shape problem. |
'drop' | Discard every event at any duplicate timestamp. Conservative — never invents a survivor. |
{ min: 'col' } | Keep the event with the smallest value at the named numeric column. Ties keep first. |
{ max: 'col' } | Keep the event with the largest value at the named numeric column. |
(events) => Event | Custom resolver. Receives all duplicates (length ≥ 2), returns one. Use for merge logic. |
- TypeScript
- Rust
// Throw on duplicates — useful in ingestion pipelines that want loud failures.
events.dedupe({ keep: 'error' });
// Keep the highest-CPU sample when two reports race at the same instant.
events.dedupe({ keep: { max: 'cpu' } });
// Custom merge: average duplicate cpu samples; keep the last host name.
events.dedupe({
keep: (events) => {
const last = events[events.length - 1];
const avg =
events.reduce((a, e) => a + (e.get('cpu') ?? 0), 0) / events.length;
return last.set('cpu', avg);
},
});
use pond_rs::{Column, Keep, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let min = 60_000;
// Hosts a and b both report at minute 1.
let events = TimeSeries::new(
"cpu",
vec![0, min, min, 2 * min],
vec![
Column::required("cpu", vec![0.5, 0.4, 0.9, 0.7]),
Column::required("host", vec!["a", "a", "b", "a"]),
],
)?;
let cpu = |s: &TimeSeries| -> Result<Vec<f64>, pond_rs::PondError> {
Ok(s.column("cpu")?.numbers()?.values().to_vec())
};
// Last wins (the default).
assert_eq!(cpu(&events.dedupe(Keep::Last)?)?, [0.5, 0.9, 0.7]);
assert_eq!(cpu(&events.dedupe(Keep::First)?)?, [0.5, 0.4, 0.7]);
// An error at the first repeated time: for pipelines that want loud failures.
assert!(events.dedupe(Keep::Error).is_err());
// Keep the highest-CPU row when two reports race at the same instant.
assert_eq!(
cpu(&events.dedupe(Keep::Max("cpu".into()))?)?,
[0.5, 0.9, 0.7]
);
// Drop every row at a repeated time.
assert_eq!(events.dedupe(Keep::Drop)?.len(), 2);
Ok(())
}
pond-rs has no custom keep function yet. Keep::Min and Keep::Max must name a number or decimal column, checked before anything runs.
Chains compose
dedupe returns the same schema, so it sits naturally before any
other transform:
events
.partitionBy('host')
.dedupe({ keep: 'last' })
.fill({ cpu: 'linear' })
.rolling('5m', { cpu: 'avg' })
.collect();
Regularize to a grid — materialize
When source events arrive at irregular times — polling drift, mixed
sample rates, sparse alerts — you often want to project the data
onto a regular grid (one row per minute, one row per second, etc.)
before downstream interpolation, charting, or alignment with another
series. materialize does exactly this: emit one time-keyed row
per sequence bucket, populated from a chosen source event in the
bucket, with undefined cells where no source event existed.
- TypeScript
- Rust
series.materialize(Sequence.every('1m'));
// → one row per minute. Empty buckets emit undefined for all
// value columns. No fill policy applied — that's a separate step.
use pond_rs::{At, Column, Materialize, Pick, Sequence, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let series = TimeSeries::new(
"cpu",
vec![5_000, 50_000, 130_000, 250_000],
vec![Column::required("cpu", vec![0.1, 0.2, 0.3, 0.4])],
)?;
let grid = series.materialize(&Sequence::every("1m")?, Materialize::default())?;
// One time-keyed row per minute whose start falls in the series' range
// (5 s to 250 s); empty buckets give gaps.
assert_eq!(grid.key_name(), "time");
assert_eq!(grid.key().begin(), &[60_000, 120_000, 180_000, 240_000]);
let got: Vec<_> = grid.column("cpu")?.numbers()?.iter().collect();
assert_eq!(got, [None, Some(0.3), None, Some(0.4)]);
// Rows at each bucket's middle, from the reading nearest it, and a range
// chosen by hand (epoch milliseconds).
let nearest = series.materialize(
&Sequence::every("1m")?,
Materialize {
sample: At::Center,
pick: Pick::Nearest,
range: Some((0, 240_000)),
},
)?;
assert_eq!(nearest.key().begin(), &[30_000, 90_000, 150_000, 210_000]);
let got: Vec<_> = nearest.column("cpu")?.numbers()?.iter().collect();
assert_eq!(got, [Some(0.2), None, Some(0.3), None]);
Ok(())
}
In Rust the options are a Materialize struct: select is called pick, and range is a pair of epoch milliseconds.
materialize is the missing piece between dedupe and fill:
| Operator | Creates rows? | Picks fill values? |
|---|---|---|
dedupe | no | no (keeps survivor) |
materialize | yes | no |
fill | no | yes |
align | yes | yes (mandatory) |
align smooshes "make the grid" with "choose how to fill it" — the
fill method ('hold' or 'linear') is mandatory. materialize
does only the first step, leaving fill policy as an explicit
follow-up. Use align when you want both at once; use
materialize when you want gap-capped fill afterwards.
Options
sample('begin' | 'center' | 'end', default'begin') — bucket anchor for the output time. Mirrorsalign's convention.select('first' | 'last' | 'nearest', default'last') — which source event in each bucket wins.'first'/'last'pick the boundary event bybegin()order.'nearest'picks the source event whosebegin()is closest to the bucket's sample time among events in the bucket — it never reaches across into a neighboring bucket. All three modes use half-open[bucket.begin, bucket.end)membership; an empty bucket emitsundefinedregardless ofselect.range(TemporalLike, defaultseries.timeRange()) — bounded slice for procedural sequences (Sequence.every(...)).
Multi-entity — partition first
On a series carrying multiple entities, the partitioned variant auto-populates the partition column on every output row, including rows from buckets where the partition had no source event:
- TypeScript
- Rust
events
.partitionBy('host')
.materialize(Sequence.every('1m'))
.fill({ cpu: 'linear' }, { maxGap: '3m' })
.collect();
// Every row carries its host even on empty buckets — no need for
// a follow-up `.fill({ host: 'hold' })` step that would fail when
// every event in a partition lies in a long-outage gap.
use pond_rs::{Column, Fill, FillLimits, Materialize, Sequence, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let events = TimeSeries::new(
"cpu",
vec![0, 10_000, 60_000, 180_000, 200_000],
vec![
Column::required("cpu", vec![0.1, 0.5, 0.2, 0.4, 0.9]),
Column::required("host", vec!["a", "b", "a", "a", "b"]),
],
)?;
let grid = events
.partition_by(&["host"])?
.materialize(&Sequence::every("1m")?, Materialize::default())?
.fill_with(&[("cpu", Fill::Linear)], &FillLimits::none().max_gap("3m"))?
.collect()?;
// Every row carries its host, even where the host had no reading.
let rows: Vec<_> = grid
.rows()
.map(|r| {
(
r.begin(),
r.string("host").unwrap(),
r.number("cpu").unwrap(),
)
})
.collect();
assert_eq!(
rows,
[
(0, Some("a"), Some(0.1)),
(60_000, Some("a"), Some(0.2)),
(60_000, Some("b"), None),
(120_000, Some("a"), Some(0.30000000000000004)),
(120_000, Some("b"), None),
(180_000, Some("a"), Some(0.4)),
(180_000, Some("b"), Some(0.9)),
]
);
Ok(())
}
End-to-end multi-entity cleaning pipeline
The canonical multi-host cleaning chain — covers the three most common ingest hazards (cross-entity neighbor leaks, duplicate rows from retried polls, fabrication across long outages) in one pipeline:
- TypeScript
- Rust
const schema = [
{ name: 'time', kind: 'time' },
{ name: 'cpu', kind: 'number', required: false },
{ name: 'host', kind: 'string' },
] as const;
const raw = TimeSeries.fromJSON({
name: 'cpu',
schema,
rows /* JSON rows from your ingest */,
});
const cleaned = raw
// Scope every following stateful op to within each host.
.partitionBy('host')
// Collapse duplicates that share (time, host); keep newest.
.dedupe({ keep: 'last' })
// Linear-interpolate cpu, but only across short gaps. A 30-minute
// outage stays unfilled — no fabricated slope across data we don't
// have.
.fill({ cpu: 'linear' }, { maxGap: '5m' })
// Materialize back to a regular TimeSeries.
.collect();
use pond_rs::{Column, Fill, FillLimits, Keep, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
// A retried poll at 0 s for host a, short gaps, and a 38-minute
// silence for host b.
let raw = TimeSeries::new(
"cpu",
vec![0, 0, 0, 60_000, 60_000, 120_000, 120_000, 2_400_000],
vec![
Column::optional(
"cpu",
vec![
Some(0.5),
Some(0.6),
Some(0.9),
None,
Some(0.8),
Some(0.7),
None,
Some(0.3),
],
),
Column::required("host", vec!["a", "a", "b", "a", "b", "a", "b", "b"]),
],
)?;
let cleaned = raw
// Scope every following step to each host.
.partition_by(&["host"])?
// Collapse rows that share (time, host); keep the newest.
.dedupe(Keep::Last)?
// Fill cpu with straight lines, but only across short gaps.
.fill_with(&[("cpu", Fill::Linear)], &FillLimits::none().max_gap("5m"))?
// Back to one series.
.collect()?;
let rows: Vec<_> = cleaned
.rows()
.map(|r| {
(
r.begin(),
r.string("host").unwrap(),
r.number("cpu").unwrap(),
)
})
.collect();
assert_eq!(
rows,
[
(0, Some("a"), Some(0.6)), // the retry won
(0, Some("b"), Some(0.9)),
(60_000, Some("a"), Some(0.6499999999999999)),
(60_000, Some("b"), Some(0.8)),
(120_000, Some("a"), Some(0.7)),
(120_000, Some("b"), None), // the long silence stays a gap
(2_400_000, Some("b"), Some(0.3)),
]
);
Ok(())
}
Each step removes one class of error:
| Step | Hazard removed |
|---|---|
partitionBy('host') | Stateful ops below stop crossing entity boundaries — host-A no longer interpolates against host-B's neighboring value. |
dedupe({ keep: 'last' }) | Retried HTTP polls / WebSocket replays at the same (time, host) collapse to the newest. |
fill({ cpu: 'linear' }, { maxGap: '5m' }) | Short gaps fill cleanly; long outages stay undefined — no invented slopes across data you don't have. |
.collect() | Returns a TimeSeries<S> ready for downstream rendering or aggregation. |
Ingest edges
Most "the data is wrong" problems happen at the boundary, not inside pipelines. The relevant docs:
- Time zones / wall-clock strings — see
JSON ingest and time zones → Wall-clock strings.
The decision table there ("does this input need
parse.timeZone?") is the single most useful artifact for ingest-side time bugs. nullvsundefined— see JSON ingest → Missing values. Wire-formatnullbecomesundefinedon read; columns markedrequired: falseaccept it.- Mixed timestamp formats — see JSON ingest → Common ingest issues. Mixing offset-qualified strings with bare wall-clock strings is a silent zone-drift footgun.
Cleaning the data after ingest can't recover from a wrong-zone parse. Get the boundary right first.