/

CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti

Telemetry Storage & Retention

This page explains how telemetry keeps years of sensor readings without the database growing without bound. It is written to be readable without knowing Timescale — every mechanism is shown on a concrete row first.

Applies to: telemetry (Rust / Postgres + TimescaleDB). Supersedes the MongoDB time-series collections used by the Node sensor-service.


The Problem, In Numbers

One building, 100 sensors, one reading every 30 seconds:

rows
per sensor per day2 880
per building per day288 000
per building per year105 million
10 buildings per year1.05 billion

At ~170 bytes a row that is roughly 180 GB a year, before indexes. Telemetry is written once and almost never read individually, so paying full price for every row is waste.

Three mechanisms cut it. They are independent — each helps on its own, and they multiply.


Lever 1 — Store Each Fact Once

A temperature reading arrives at POST /ingest:

{
  "buildingId": "b1",
  "roomId": "r1",
  "timestamp": 1700000000000,
  "temperature": 21.5
}

The naive row stores all of it twice — once in typed columns, once again in the payload JSON blob:

building_idroom_idmetrictsvaluepayload
b1r1temperature2023-11-14 22:13:20+0021.5{"buildingId":"b1","roomId":"r1","timestamp":1700000000000,"temperature":21.5}

JSON stores key names as text on every row. "buildingId" is 12 bytes, repeated 288 000 times a day, for a value that is already in the column next to it.

Rule: payload holds only what no column holds.

metricpayload beforepayload after
temperature4 keys{}
peopleCount4 keys{}
airQuality12 keys8 keys — pm25, pm10, co2, voc, temperature, humidity, aqi, scenario

The stored row becomes:

building_idroom_idmetrictsvaluepayload
b1r1temperature2023-11-14 22:13:20+0021.5{}

Nothing is lost. building_id, room_id and ts are the columns; value is the measurement the plugin designated. GET /{metric}/latest reassembles the original envelope from the columns before answering.

The list is not hand-maintained: MetricDescriptor.fields names every field a metric declares, and the plugin names the one that became value. Everything else is what gets kept.

The trim affects storage only, not the wire

The Redis telemetry:raw event still carries the full payload. This changes what is written to disk, not what is published — dashboard and the browser see no difference.


Lever 2 — Squeeze Rows That Repeat Each Other

Room r1 writes 2 880 rows a day. Look at what they have in common:

b1 | r1 | temperature | 22:13:20 | 21.5 | {}
b1 | r1 | temperature | 22:13:50 | 21.6 | {}
b1 | r1 | temperature | 22:14:20 | 21.6 | {}
...2877 more, all starting the same way

b1, r1 and temperature are identical on every line. The timestamps rise by exactly 30 000 ms. The values barely move.

Timescale can rewrite a batch of up to 1 000 such rows into one compressed row:

alter table readings set (
  timescaledb.compress,
  timescaledb.compress_segmentby = 'building_id, room_id, metric',
  timescaledb.compress_orderby   = 'ts desc'
);
select add_compression_policy('readings', interval '7 days');

Typical result on telemetry: 10–20× smaller.

Only data older than 7 days is compressed. Recent data stays in ordinary row form, so ingest stays fast and GET /{metric}/latest is unaffected. Compression runs in the background as chunks age past the threshold.


Lever 3 — Keep Summaries, Not Every Reading

Nobody asks what a room measured at 03:41:30 last November. They ask for a chart.

After 14 days, individual readings are deleted and replaced by one row per hour:

create materialized view readings_hourly
with (timescaledb.continuous) as
select time_bucket('1 hour', ts) as bucket,
       building_id, room_id, metric,
       avg(value) as avg, min(value) as min, max(value) as max,
       sum(value) as sum, count(*) as samples
from readings
group by 1, 2, 3, 4;

120 raw readings per hour collapse to a single row carrying average, minimum, maximum, sum and sample count. That is 120× fewer rows, kept indefinitely.

readings itself is dropped after 14 days:

select add_retention_policy('readings', interval '14 days');

The view refreshes incrementally on a schedule and tolerates late-arriving readings within a 3-day window, so a sensor that reconnects after an outage still lands in the right bucket.

Which table a query reads

Decided entirely by the requested time range, in the adapter:

rangebucket sizesource
1D1 hourreadings
1W1 dayreadings_hourly
1M1 dayreadings_hourly
custom1 dayreadings_hourly

TimeRange::bucket_interval() in the kernel already returns "1 hour" for 1D and "1 day" otherwise, so the routing is one branch in the persistence adapter. No kernel code knows either table exists.

avg past 14 days is an average of averages

min and max stay exact — the extreme of hourly extremes is the true extreme. avg is not: averaging hourly averages differs from a true average whenever hours hold unequal sample counts, so a sensor offline for 40 minutes weighs the same as a full hour. samples is stored for exactly this reason — weight by it if a use case ever needs the precise figure.


Combined Effect

10 buildings, 100 sensors each, one reading every 30 seconds:

one year of data
nothing applied~180 GB
lever 1 — no duplicated payload~110 GB
+ lever 2 — compression after 7 days~8 GB
+ lever 3 — 14-day raw, hourly rollup~2 GB

Estimates, not measurements. payload width dominates and depends on the real airQuality mix. Verify with:

select pg_size_pretty(hypertable_size('readings'));
select * from hypertable_compression_stats('readings');

A compression ratio below ~8× means payload is carrying more than it should.


Compared With MongoDB Time-Series

The Node sensor-service used Mongo time-series collections. The underlying idea is the same — batch measurements, store repeated identifiers once, delta-encode time — so this is not a change of strategy, but the details differ.

Mongo time-seriesTimescale
batching unitbucket: same metaField, 1 h span, ≤1 000 measurementscompressed row: ≤1 000 rows per segment
identifiers stored oncemetaField only — was buildingevery segmentby column — building_id, room_id, metric
when it compresseson bucket close, alwaysafter 7 days, by policy
recent dataalready compressedplain rows, fully mutable
rollupsnot built incontinuous aggregate

Three practical differences:

Where Mongo was better: compression was immediate and unconditional, and typed schema fields meant no JSON key repetition — which is precisely the gap lever 1 closes.


Where This Lives

concernfile
tables, hypertable, compression, retentionbackend/telemetry/migrations/0002_timescale.up.sql
hourly rollupbackend/telemetry/migrations/0003_rollup.up.sql
payload trim on write, envelope re-inflation on readbackend/telemetry/src/adapters/driven/postgres/readings.rs
bucket size per rangebackend/telemetry/src/types/query.rs

See also Communication & Data Flow.