navree
Documentation

TimescaleDB

Navree's default database: why it is the default, running it locally and in production, which tables to make hypertables (Navree's own included), keys and ids, chunks, compression, retention, continuous aggregates and time-bucket queries.

On this page

TimescaleDB is Navree's default database: PostgreSQL with an extension for time-series data. Its central idea is the hypertable, a table PostgreSQL stores as many chunks, each holding one span of time. Queries over a recent window read only the chunks they need, old chunks can be compressed to a fraction of their size or dropped whole, and continuous aggregates keep rollups (events per day, revenue per hour) up to date incrementally. To your queries a hypertable is an ordinary table.

You decide in your migrations which tables become hypertables, with the schema builder. The same migration runs on plain PostgreSQL, MySQL, MariaDB and SQLite, which get a regular table with the right indexes and a notice.

Why TimescaleDB is the default

  • It is PostgreSQL. Everything Navree does on PostgreSQL works the same: the unit of work and its navree.actor setting for triggers, row-level security for workspaces, JSONB, FOR UPDATE and SKIP LOCKED for the queue, full-text search. Your business records are regular tables, as they would be on any PostgreSQL server.
  • Every app keeps time series. The audit trail, webhook deliveries, job history and monitoring events grow with every request, are read by time range, and expire. As regular tables they slow down as they grow, and deleting old rows leaves the table bloated. As hypertables, recent data stays fast, old data compresses, and expired data is dropped a chunk at a time. Navree's own time-series tables are hypertables from the start.
  • One database. Time series live next to the records they describe, in the same transactions and joins, so there is no second store to run, back up and keep in step.
  • Nothing is locked in. A table is regular until a migration declares it a hypertable, and the migration also runs where TimescaleDB isn't installed. Your tests run the production migrations on SQLite.

Running it

A new app is set up for TimescaleDB. navree new writes a PostgreSQL URL into config/app.toml, turns on the postgres feature of the navree dependency, and adds a dock.toml whose postgres service runs TimescaleDB:

Shell

navree new my-shop --database postgres sets up plain PostgreSQL instead, --database mysql MySQL or MariaDB, and --database sqlite a SQLite file with nothing to install.

An existing app needs the postgres feature of the facade and a server. Dock runs one for development:

Shell

In production, point APP_DATABASE__URL at a TimescaleDB server: a managed TimescaleDB service, or PostgreSQL 16 to 18 with the timescaledb package installed and shared_preload_libraries = 'timescaledb'. The first migration that declares a hypertable runs CREATE EXTENSION IF NOT EXISTS timescaledb when the database user may, or ask your administrator to run it once. Then make the extension a requirement, so a server without it stops the migration instead of falling back to regular tables:

Shell

navree migrate prints a notice for every table it couldn't make a hypertable. See the extension for the three modes.

Which tables to convert

Make a table a hypertable when it is:

  • append-mostly: rows are inserted and rarely updated or deleted one by one;
  • keyed by time: every row has a timestamp, and queries ask for a time range (the last hour, this month);
  • large, or growing without end: millions of rows, most of them old.

Good candidates: request and application logs, events and analytics, the audit trail, metrics and readings, notification and webhook delivery history, job runs. Keep as regular tables the records your app updates in place and points foreign keys at: users, workspaces, companies, deals, orders, settings. A hypertable is a poor fit for a table referenced by other tables' foreign keys, or one whose rows change state for months.

Navree's modules make their time-series tables hypertables in their own migrations, so an app on TimescaleDB gets them without writing anything:

ModuleTableTime columnChunks ofKept for (setting)
Auditaudit_logscreated_at7 days365 days (audit.retention_days)
Webhookswebhook_deliveries, webhook_attemptscreated_at1 day30 days (webhooks.retain_days)
Fleetfleet_jobsfinished_at, Unix milliseconds1 hour60 minutes (fleet.trim.recent_minutes)
Fleetfleet_metricsat, Unix milliseconds1 day7 days (fleet.trim.metrics_days)
Schedulingschedule_runstick1 day7 days
Lighthouselighthouse_samples, lighthouse_occurrencesat1 day14 days (lighthouse.retention_days)
Lighthouselighthouse_aggregatesbucket1 day14 days (lighthouse.retention_days)

On TimescaleDB their primary keys become (id, <time column>) (the scheduler's (task, tick) already has its time), and rows already there move into chunks when the migration runs, so an existing app converts on upgrade. That locks each table while its rows move: on a large audit log, run the upgrade in a quiet window. On every other database the tables stay as they were, with an index on their time column, and the migrations report a notice.

Their prune commands (audit:prune, webhooks:prune, lighthouse:prune, Fleet's trimming and the scheduler's hourly cleanup) drop the chunks that lie entirely before the cutoff and delete only the rows of the chunk that straddles it (pruning in code shows the helper they use). The retention stays in your configuration, so changing audit.retention_days needs no migration.

Webhook attempts show the key rule at work: a hypertable's key includes its time column, so no foreign key can point at a delivery's id alone. Attempts reference their endpoint instead (deleting an endpoint still deletes its deliveries and attempts), and webhooks:prune deletes a delivery's attempts with it.

Every other table Navree creates (users, workspaces, the queue's jobs and failed_jobs, Lighthouse's issues, webhook endpoints) stays a regular table: their rows are updated in place, picked one at a time, or referenced by foreign keys.

To size the chunks for your traffic, or add policies, change Navree's tables in a migration of your own:

src/modules/ops/migrations/m20261101_090000_tune_time_series.rs

On a database without TimescaleDB both calls report a notice and change nothing. A retention policy you add runs on its own schedule, next to the prune commands: keep its interval no shorter than the module's setting.

With lighthouse.database_url, Lighthouse keeps its tables in a database of its own. It makes the same three tables hypertables there when it connects at boot, if that database runs TimescaleDB.

Creating hypertables

create_hypertable creates a table partitioned by a time column:

src/modules/analytics/migrations/m20261027_090000_create_events_table.rs

Inside the closure, t.hypertable("occurred_at") returns the hypertable to configure. You can also declare it inside an ordinary create; schema.create(..., |t| { ...; t.hypertable("occurred_at"); }) does the same as create_hypertable. On TimescaleDB this runs:

SQL

The time column may be a timestamp_tz (the usual choice), a timestamp, a date, or an integer (milliseconds since the epoch, a sequence). An integer time column takes integer chunk intervals (chunk_interval("86400000")); a temporal one takes intervals such as "1 day", "12 hours" or "2 weeks". A mismatch, a missing time column or an interval that doesn't parse fails before any SQL runs.

Converting an existing table

An existing table becomes a hypertable with convert_to_hypertable:

Rust

A short description can be passed as it is built: schema.convert_to_hypertable("visits", Hypertable::on("created_at").migrate_data()). A table that has rows needs .migrate_data(), which moves them into chunks and locks the table while it runs; without it, the conversion stops and says so. schema.is_hypertable("visits") tells whether a table already is one, which keeps a conversion you might run twice from doing anything the second time.

Keys and ids

TimescaleDB enforces uniqueness inside each chunk, so every primary key and unique index of a hypertable must include its time column (and its partition column, below).

  • A primary key that lacks them is widened: uuid_primary() on events becomes PRIMARY KEY (id, occurred_at), and the migration reports it. The key is widened on every database, so a migration gives the same keys on SQLite in your tests as on TimescaleDB in production. (SQLite keeps an id() auto-increment key alone, as it must, and says so.) With .keys(KeyStrategy::Reject) on the hypertable, or schema.hypertable_keys = "reject" for every hypertable, the migration fails instead and tells you the key to declare.
  • A unique index that lacks them can't be widened without changing what it means, so it is an error on every database: add the time column to it, or drop it.
  • Converting an existing table rebuilds its primary key the same way, unless other tables' foreign keys point at it; then the conversion stops and names them. Point those foreign keys at a regular table the rows belong to (an endpoint, a device, a workspace), as webhook attempts do, and delete the dependent rows when you prune.

Use time-ordered ids for time-series tables: UUID v7 (what Navree generates for Uuid keys) or ULID (ulid_primary()). Their leading bits are a timestamp, so new rows land at the end of the index, ids sort by creation time, and a lookup by id alone still uses the (id, occurred_at) key. Two ways to key a time-series table:

DeclaresFits
A time-ordered idt.uuid_primary() (widened to (id, occurred_at))rows you fetch or link by id: events, audit entries, deliveries
A natural keyt.primary(["device_id", "taken_at"])readings with one value per source and instant

Your code keeps finding rows by id: find_by_id, updates and deletes filter on id and work the same on a hypertable.

Chunks and partitions

TimescaleDB's default chunk spans 7 days. Size chunks so the indexes of the most recent chunk fit in about a quarter of the server's memory: a table receiving millions of rows a day wants chunk_interval("1 day") or less, a quiet one can keep the default. schema.set_chunk_interval("events", "6 hours") changes it for chunks created from then on.

schema.time_dimension("events") reads a hypertable's time column, its type and its chunk interval from TimescaleDB's catalog, and gives None for a regular table:

Rust

Space partitioning

partition_by("workspace_id", 4) also splits each time span into 4 partitions by hashing a column. It helps when the data is spread over several disks, and rarely on a single server. A partitioned hypertable's keys must include the partition column too, and the builder widens them to (id, occurred_at, workspace_id).

Compression

Rust

Compression (TimescaleDB's columnstore) stores old chunks by column, often at a tenth of their size. segment_by lists the columns your queries filter on: rows are grouped by them, so a query for one workspace reads one segment. Pick columns with many rows per value (a workspace, a device), not unique ones. order_by orders rows inside a segment, usually the time column descending. after adds a policy that compresses chunks once they are that old; leave the recent chunks, which still receive writes, uncompressed. On an existing hypertable use schema.compress("events", &compression), add_compression_policy and remove_compression_policy.

Retention

retain_for("90 days") adds a policy that drops chunks older than 90 days, whole, without the cost of a DELETE. On an existing hypertable use schema.add_retention_policy("events", "90 days") and remove_retention_policy.

Pruning in code

When the retention period is a setting, or other rows go with the old ones, prune from code: a command or a scheduled task. navree::db::prune_time_series deletes the rows older than a cutoff and returns how many. On a hypertable partitioned by that column it drops the chunks that lie entirely before the cutoff and deletes only the rows of the chunk that straddles it; on any other table it is one DELETE:

src/modules/analytics/prune.rs

The cutoff has the time column's type: a DateTime<Utc> for timestamps, an i64 for integer time columns (TimePoint::Integer). Navree's own prune commands use this helper.

Reorder policies

reorder_by("events_workspace_id_occurred_at_index") rewrites each chunk in the order of that index once it stops receiving writes, so range queries on that index read neighbouring rows. Declare the index in the same migration. On an existing hypertable: add_reorder_policy and remove_reorder_policy.

Continuous aggregates

A continuous aggregate is a materialized view over a hypertable that TimescaleDB keeps up to date by refreshing only the time ranges that changed:

Rust

The refresh policy refreshes the window from start_offset ago to end_offset ago every every; leave the most recent span (end_offset) to be computed when read. .materialized_only(false) makes queries add the newest raw rows to the materialized ones. You can also give the query as SQL, ContinuousAggregate::new("events_daily", "SELECT time_bucket(...) ..."); SQL that calls time_bucket only runs where TimescaleDB is installed, and elsewhere the migration stops and suggests the described form.

drop_continuous_aggregate, add_continuous_aggregate_policy, remove_continuous_aggregate_policy and refresh_continuous_aggregate manage them later. TimescaleDB refuses to refresh inside a transaction, so a migration that refreshes returns false from within_transaction.

A continuous aggregate is a view, so you read it like a table: table("events_daily") in the query builder.

Time-bucket queries

The query builder groups rows into time buckets with TimescaleDB's time_bucket: the start of the bucket each row's time falls in. A chart of the last day, one point per 15 minutes:

Rust

Buckets start at Monday, 2000-01-03, plus whole widths, as TimescaleDB's do; Expr::time_bucket_from("1 day", col("taken_at"), origin) aligns them to another origin, such as midnight in a time zone. Widths are intervals: "30 seconds", "5 minutes", "1 hour", "1 day", "1 week", "1 month".

first and last are TimescaleDB's aggregates for the value at the earliest and latest time of each group, such as a day's opening and closing reading:

Rust

On SQLite and MySQL, where your tests run, the builder computes fixed-width buckets (from microseconds to weeks) itself, with the same alignment, so the same query returns the same buckets. Month and year buckets, first and last need TimescaleDB, and elsewhere the query fails with an error naming what is missing.

One migration, every database

A migration that declares a hypertable runs everywhere:

DatabaseGets
TimescaleDBA hypertable with its dimensions and policies; continuous aggregates.
PostgreSQL without TimescaleDBA regular table with an index on (occurred_at DESC) (and (partition, occurred_at DESC)), unless one already starts with them; continuous aggregates as plain views bucketing with date_bin.
MySQL and MariaDBThe same indexes; views bucketing with epoch arithmetic.
SQLiteThe same indexes; views bucketing with strftime.

Each fallback reports a notice naming what it skipped:

Text

So your tests run the production migrations on SQLite, and a plain PostgreSQL server works too, without the time-series features. A view bucket is computed when read; the buckets line up with TimescaleDB's (time_bucket counts from Monday 2000-01-03), so the same query gives the same buckets everywhere.

The extension

schema.timescaledbBehaviour
"auto" (default)Uses TimescaleDB when the database has it. When the extension is available but not installed (the server preloads it and the database user is a superuser), the first hypertable runs CREATE EXTENSION IF NOT EXISTS timescaledb, unless schema.create_extensions = false. Otherwise it falls back.
"require"Fails a hypertable migration on a PostgreSQL database without TimescaleDB, so production can't silently run without it. Other databases still fall back.
"off"Never uses TimescaleDB; hypertables are regular tables everywhere.

Set require in production's environment (APP_SCHEMA__TIMESCALEDB=require) and keep auto for development and tests. A new app's config/app.toml sets timescaledb = "auto" in its [schema] section.

Dumps

navree schema:dump writes hypertables, their dimensions, compression settings, policies and continuous aggregates into the schema dump through TimescaleDB's functions, so a database loaded from the dump is a TimescaleDB database with the same hypertables.