Sparse measures: composite keys, time, and archiving (proposal)¶
SparseTable as it stands identifies each value by one flat name, and offers one wide view with a
column per name. That fits a few hundred names. The workload it is meant for is different:
- Thousands of records, each carrying hundreds to thousands of values.
- Each value identified by several coordinates - a few text parts plus one point along an axis.
- Thousands of distinct keys across the table, so "a column per key" is thousands of columns.
- One record can hold two values for the same key, in different units.
- Values change over time, sometimes as a complete re-publish, sometimes as only what changed.
- Values can disappear, not only change.
- History has to be kept, but not in the live table forever.
This proposal reshapes SparseTable around that. Everything here was measured first; the numbers
are at the end.
Defining a measure¶
One table per measure. The parts are fixed when it is defined.
SparseTable measures = SparseTable.named("measures")
.withKey("a", "b", "c") // text parts identifying a series
.withAxis("point") // one text part, laid out as columns
.withUnit("unit") // part of the identity; never summed across
.appendMode(); // or replaceMode(), the default
- Every part is text, numbers included. The columns are fixed, so nothing is gained by typing them.
- The axis is the part that becomes columns. Each axis value may carry a sort position; without
one, columns come out in text order, which is already right for dates written
2026-10-29. - The unit is part of what identifies a value, because one record can hold the same key in two units. It carries one rule, below.
- Records are identified by a unique id and nothing else. Anything else known about a record belongs in your own table; every query can hand back its SQL to join against.
Never add different units¶
A total across two units is a wrong number that looks right. So totals() refuses to run unless the
unit is either grouped by or filtered to a single value:
measures.totals().rows("a", "unit").columns("point") // fine: grouped by unit
measures.totals().where("unit", "U1").rows("a") // fine: one unit
measures.totals().rows("a").columns("point") // refused
Storage¶
measures_key measures_value
id | a | b | c | point | unit | point_order record_id | [ts] | key_id | value | kind
---+---+---+---+------------+------+------------ ----------+------+--------+-------+------
0 | x | y | z | 2026-10-29 | U1 | 1 42 | ... | 0 | 1.25 | ...
1 | x | y | z | 2026-10-29 | U2 | 1 42 | ... | 1 | 0.97 | ...
The dictionary gains a column per key part; it stays small (9,000 rows in the measured case), which
is why parsing coordinates out of a flat name costs almost nothing today - and why doing it properly
costs nothing either. The values table keeps its narrow shape: the ts and kind columns exist only
in append mode.
Writing¶
| mode | call | meaning |
|---|---|---|
| replace | replace(batch) |
these records' values become exactly this. No history. |
| append | publishFull(batch, at) |
the record's complete set of values at that timestamp |
| append | publishChanges(batch, at) |
only the values that changed at that timestamp |
A change can also be a removal. batch.record(42).remove("x", "y", "z", "2026-10-29", "U1") writes a
marker row, so that reading as of a later time knows the value is gone rather than unchanged.
Reading¶
Every question has the same shape: which parts become rows, the axis (or time) becomes the columns, filters, and a point in time.
measures.view().records(42).asOf(t).rows("a", "b", "c").columns("point")
measures.totals().asOf(t).where("unit", "U1").rows("a").columns("point")
measures.totals().between(t1, t2).rows("a").columns("time")
measures.totals().change(t1, t2).rows("a").columns("point")
measures.totals().rows("a").columns("point").sql() // to join your own tables to
As of a timestamp means: take the record's latest full publish at or before it, apply every change after that up to it, keep the newest value for each key, and drop the keys whose newest row is a removal.
Totals compute per key first. Adding up 10.2M values per key gives 9,000 rows, and only then is the dictionary joined and the columns laid out: 20 ms against 227 ms for the obvious order. This is the single biggest change and it is in the generated SQL, not in the storage.
Rollups, optional, for many readers at once: one total per key, answering in about 1 ms, rebuilt in about 40 ms, or kept current as records are replaced at about 11 ms a record.
What is built¶
All of it except timestamps: keys with named fields, the unit rule and conversion, declared orders,
grouping and pivoting on any field, parts and provenance, archiving a part to one self-contained
Parquet file, querying those files where they lie, restoring them, fields added later, and archiving
by size. See docs/measures.md, MeasureTable, and the examples CoreMeasures.java and
CoreMeasureLifecycle.java.
What is not built is the timestamped history described below - full publishes and incremental
changes at a point in time, reading "as of", and removals. Parts cover the archiving side of it;
appendChanges covers writing only what moved.
Archiving¶
Once a full publish exists at time T, everything before it can leave the live table without changing any answer at or after T.
measures.export(connection, directory); // built: dictionary, values, definition
measures.query().from(directory).rows("a") // built: ask the files, without reading them back
measures.importFrom(connection, directory); // built: read them back, matching keys by field
measures.archive(before, directory); // not built: the same, for writes older than a time
Reading an archived time means reading those files, which the builder can do on request. The live database file does not shrink when rows are deleted; DuckDB reuses the space.
Measured¶
On this machine (10 cores, DuckDB 1.5.5), before building anything:
| totals by two parts, axis as 30 columns, 10.2M values: join first | 227 ms |
| the same, totalling per key first | 20 ms |
| the same, from a per-key rollup (9,000 rows, built in 15 ms) | 7 ms |
| one record, axis as columns | 7-12 ms |
| keeping the rollup current per replaced record (1,000 values) | 10.7 ms; matched a rebuild to 5e-11 after 200 replaces |
| 39M value rows over 200 timestamps, full publish every 20th | 238 MB |
| one record as of a timestamp (full + changes on top) | 5.2 ms |
| totals across 2,000 records as of a timestamp | 42 ms |
| flushing 23.4M rows to Parquet, partitioned by day | 382 ms, 27 MB (this data compresses unusually well) |
| one record as of an archived timestamp, from Parquet | 40 ms, or 12 ms reading only that day |
| live and archived together, latest per key | 290 ms |
What it costs to build¶
SparseBatchtakes key parts rather than one name, and gains removals.SparseTablegains the definition (parts, axis, unit, mode), the dictionary columns, the write modes, and the query builder that generates all the SQL above.- The flat-name API released in 1.1.0's notes but not yet in a release goes away. A flat name is the
same thing as a key with one part, so a table defined
withKey("name")behaves as before.
Open¶
- Whether
between(...)should put time in the rows or the columns by default. Columns reads better for a handful of timestamps, rows for hundreds. - Whether removals need to be visible in a view - shown as an empty cell, which they will be, or distinguishable from "never had a value".
- Whether the rollup should be maintained automatically or left for the caller to refresh.