Skip to main content

TimescaleDB

Iotistica uses TimescaleDB on top of PostgreSQL 16 for all time-series storage. TimescaleDB provides automatic chunk partitioning, native compression, continuous aggregates, and data retention policies — all managed transparently by the database.

All hypertables, compression settings, retention policies, and continuous aggregates are applied by the database migrations under api/database/migrations/.


Hypertables

Five tables are converted to TimescaleDB hypertables. Each is partitioned by time into fixed-size chunks that are compressed and eventually dropped automatically.

HypertableTime columnChunk intervalPurpose
readingstime1 dayNormalized sensor telemetry — the primary time-series table
agent_metricsrecorded_at7 daysAgent system health: CPU, memory, storage, temperature
agent_logstimestamp1 dayAgent and service log events
mqtt_broker_statstimestamp1 dayBroker-level statistics: connected clients, message throughput
mqtt_topic_metricstimestamp1 dayPer-topic message counts, QoS distribution, byte rates

readings Schema

The primary hypertable. Every sensor reading from every protocol (Modbus, OPC-UA, BACnet, MQTT, SNMP, CAN) lands here as a normalized row.

CREATE TABLE readings (
time timestamptz NOT NULL,
agent_uuid uuid NOT NULL,
metric_name text NOT NULL, -- e.g. "plc-1.temperature"
value double precision,
quality text DEFAULT 'good', -- good | bad | uncertain
unit text, -- °C, %, V, hPa, …
protocol text NOT NULL, -- modbus | opcua | mqtt | …
extra jsonb DEFAULT '{}', -- deviceName, slave_id, nodeId, …
anomaly_score double precision, -- 0–1, NULL if not computed
anomaly_threshold double precision,
PRIMARY KEY (agent_uuid, metric_name, time)
);

Key indexes:

  • (agent_uuid, time DESC) — device + time range queries
  • (metric_name, time DESC) — cross-agent metric queries
  • (protocol, time DESC) — filter by protocol
  • GIN (extra) — JSONB metadata queries

Compression Policies

All hypertables use LZ4-based column compression. Compression is applied automatically to chunks older than the threshold. Typical storage reduction is 85–95% for sensor data.

HypertableCompress afterSegment byOrder by
readings7 daysagent_uuid, metric_nametime DESC
agent_metrics7 daysagent_uuidrecorded_at DESC
agent_logs7 daysagent_uuid, service_nametimestamp DESC
mqtt_broker_stats7 daystimestamp DESC
mqtt_topic_metrics7 daystopictimestamp DESC

compress_segmentby groups related rows together within each chunk before compression, improving both compression ratio and query performance on the segmented columns. Queries on compressed chunks are served without explicit decompression — TimescaleDB decompresses on the fly at the chunk level.


Retention Policies

Data older than the retention threshold is automatically deleted by a background TimescaleDB job. The job runs once per day.

HypertableRetention
readings730 days (2 years)
agent_metrics90 days
agent_logs90 days
mqtt_broker_stats180 days
mqtt_topic_metrics180 days
note

Retention applies to raw hypertable chunks. Continuous aggregates have independent refresh and retention policies and are not deleted when raw data is dropped.


Continuous Aggregates

TimescaleDB continuous aggregates are materialized views that are refreshed incrementally in the background. They pre-aggregate raw data into coarser time buckets so dashboard queries hit the aggregate instead of scanning raw chunks.

readings aggregates

ViewBucketRefresh scheduleStart offsetEnd offsetColumns
readings_1m1 minuteevery 1 min1 hour1 minuteavg/min/max value, sample_count, quality_ratio, anomaly metrics
readings_1h1 hourevery 1 hour1 day1 houravg/min/max/stddev value, sample_count, quality_ratio
readings_hourly1 hourevery 1 hour3 hours1 houravg/min/max/stddev, first/last value + timestamp
readings_daily1 dayevery 1 day3 days1 dayavg/min/max/stddev value, sample_count

readings_1m and readings_1h materialize extra->>'deviceName' and protocol for per-device, per-protocol breakdown. readings_hourly and readings_daily aggregate at the (agent_uuid, metric_name, protocol) grain without device name (used by fleet-level dashboards).

agent_metrics aggregates

ViewBucketRefresh scheduleColumns
agent_metrics_5min5 minutesevery 5 minavg/max/min cpu_usage, cpu_temp, memory/storage usage
agent_metrics_hourly1 hourevery 1 hoursame
agent_metrics_daily1 dayevery 1 daysame

agent_logs aggregates

ViewBucketRefresh scheduleColumns
device_logs_5min5 minutesevery 5 minper-service error/warn/info/debug counts, error_samples
device_logs_hourly1 hourevery 1 hoursame

mqtt_broker_stats aggregates

ViewBucketRefresh scheduleColumns
mqtt_broker_stats_5min5 minutesevery 5 minavg/max connected_clients, message rates, byte totals
mqtt_broker_stats_hourly1 hourevery 1 hoursame
mqtt_broker_stats_daily1 dayevery 1 daysame

mqtt_topic_metrics aggregates

ViewBucketRefresh scheduleColumns
mqtt_topic_metrics_5min5 minutesevery 5 minper-topic message counts, byte totals, QoS distribution
mqtt_topic_metrics_hourly1 hourevery 1 hoursame
mqtt_topic_metrics_daily1 dayevery 1 daysame

Operational Queries

Check hypertables:

SELECT hypertable_name, num_chunks, compression_enabled
FROM timescaledb_information.hypertables;

Check compression and retention jobs:

SELECT hypertable_name, proc_name, schedule_interval, next_start, last_run_status
FROM timescaledb_information.jobs
WHERE proc_name IN ('policy_compression', 'policy_retention')
ORDER BY hypertable_name;

Check continuous aggregate refresh policies:

SELECT view_name, schedule_interval, next_start, last_run_status
FROM timescaledb_information.jobs j
JOIN timescaledb_information.continuous_aggregates ca
ON j.hypertable_id = ca.mat_hypertable_id
ORDER BY view_name;

Check storage savings:

SELECT
hypertable_name,
pg_size_pretty(before_compression_total_bytes) AS before,
pg_size_pretty(after_compression_total_bytes) AS after,
ROUND(
(1 - after_compression_total_bytes::numeric / NULLIF(before_compression_total_bytes, 0)) * 100, 1
) AS pct_saved
FROM chunk_compression_stats('readings')
ORDER BY chunk_name;

Ingest stream depth (consumer lag check):

SELECT chunk_name, range_start, range_end, is_compressed, num_rows
FROM timescaledb_information.chunks
WHERE hypertable_name = 'readings'
ORDER BY range_end DESC
LIMIT 10;