Schema migrations
Baler's Postgres schema is a numbered list of migrations in
src/baler/migrations/, and every database records which ones it has
(Baler#194). A building's record has to last 15+ years on one host, so the
schema must be able to change in place, not only grow new tables.
Until this, every service ran its own CREATE TABLE IF NOT EXISTS ... each
time it connected. That added tables well, but it couldn't change a column
safely, recorded nothing, and ran DDL checks on every new connection of every
service.
How it runs
- Once per deploy, before any service. The
migratecompose service runspython -m baler.migrate, and every daemon waits for it to finish. It applies whatever migrations are pending, then does the Timberdoodle graph rewrite it always did. On a current database it logsschema: at migration N, nothing to do. On nash-srv, a deploy that adds a migration always takes the full path, which runsmigrate, never the restart-only fast path (deploy/nash-srv/setup-runner.sh; it takes effect once the wrapper is reinstalled by rerunning that script). - Never per connection. Each process checks the recorded version once per
database, on its first connection (
baler.schema.ensure, called bytimeseries.connect/connect_pooland every module'sensure_schema). After that a new connection costs no catalog reads and takes no locks. If the database is behind, which happens to a process started without themigrateservice (bare metal, tests), that process upgrades it. That suits light migrations. Before starting services on a heavy one (one that rewrites chunks), runpython -m baler.schema upgradeordocker compose run --rm migrateyourself, so it isn't run inside a service's startup, whose connection pool gives up after 30 s. - One at a time, each exactly once. Each migration runs in its own
transaction, under the advisory lock the old connect-time DDL took
(
ddl.LOCK_KEY), so new and old code take turns during a deploy. After taking the lock it re-reads the recorded versions, so ten processes starting at once apply each migration once. Its row inbaler_schema_migrationscommits with it: a failed migration leaves nothing applied and nothing recorded, and the next run tries it again from the start. The migration transaction has no statement timeout.
The migrate service connects with the same BALER_PG_DSN as every daemon,
built from BALER_PG_USER/BALER_PG_DB, so an install still on the old
timberdoodle role and database (see deploy/MIGRATING-TO-BALER.md) is
migrated in place.
Commands
Code
They use BALER_PG_DSN, or --dsn. Inside the stack:
docker compose run --rm migrate python -m baler.schema status.
plan only reads. It plans each pending migration against the database as it
is now; upgrade plans each one again just before running it, after the ones
before it have run.
stamp is for a database whose schema someone brought to a version by other
means. You won't need it for an install made by Baler's own code: the baseline
adopts those by itself.
What's recorded
Code
how is applied, adopted (the baseline on a database that already had
Baler's tables) or stamped. statements is how many statements ran on
this database.
The baseline, and the live install
Migration 0001 (v0001_baseline.py) is a frozen copy of the DDL the last
release before migrations ran at connect time (main at 6946378): about 40
tables, point_history as a hypertable with its index, continuous aggregates,
their refresh policies, compression and the compression policy.
On a fresh database it builds all of that. On an existing one, nash-srv's included, it adopts what's there. Nothing destructive runs, and nothing is rebuilt:
point_history's pieces are each checked in the catalog first, and only what's missing runs. An install made by the previous release has all of them, so nothing touchespoint_history, its chunks or their compression. This matters because even a no-opCREATE INDEX IF NOT EXISTStakes a SHARE lock on every chunk (Baler#107).- The other tables' statements are the same idempotent
IF NOT EXISTSforms every service already ran on every start. On an install that has the tables they change nothing.
The upgrade test below checks exactly this. It builds a database with the previous release's own code, compressed chunks included, adopts it, and requires the result to be identical to a fresh database and every row and compressed chunk to be unchanged.
point_history_empty, the Cx report's helper index, isn't part of any
migration. commissioning_api builds it in the background, one chunk per
transaction (WITH (timescaledb.transaction_per_chunk), Baler#237), which
can't run inside a migration's transaction, and reads work without it.
Writing a migration
-
Add
src/baler/migrations/vNNNN_<what>.pywith the next number, and list it inmigrations/__init__.py'sALL.tests/test_schema.pychecks the numbers run 1..N with no gap.Code -
plan(conn)returns the statements; it must not change anything itself. It may look in the catalog to skip what's already there. -
Everything runs in one transaction, so no
CREATE INDEX CONCURRENTLY, notransaction_per_chunk, and noVACUUM. Things that can't run in a transaction belong in a background task, aspoint_history_emptydoes. -
Never edit a migration once it's on main. It has to mean the same thing on every database forever.
tests/test_schema.pypins each released file's sha256; add a new migration instead. A comment-only fix is the one reason to update a hash there. -
Add a line to the module whose table it is, if a reader there would look for it.
-
Migrations only go forward. To undo a change, write the next migration. To roll a deploy back, older code runs on a newer schema:
ensurelogs a warning and carries on. Make migrations additive where you can, so that holds. To undo a migration that destroyed data, restore a backup (see Backups).
Hypertables and compressed chunks
What TimescaleDB 2.31 (the version our pinned image runs) does with an
ALTER on a hypertable whose chunks are compressed. These were measured on
that image, not taken from the docs:
| Change | On compressed chunks |
|---|---|
ADD COLUMN, nullable or with a default | works |
RENAME COLUMN, including a segmentby column | works |
DROP COLUMN of a non-segmentby, non-orderby column | works |
ALTER COLUMN SET/DROP DEFAULT, SET/DROP NOT NULL | works |
CREATE INDEX, ADD CONSTRAINT ... CHECK | works |
ALTER COLUMN ... TYPE | refused: operation not supported on hypertables with compressed chunks |
DROP COLUMN of a segmentby or orderby column | refused |
SET (timescaledb.compress = false) | refused while any chunk is compressed |
new compress_segmentby / compress_orderby | accepted, but only chunks compressed later use it; existing ones need compress_chunk(c, recompress => true) |
For the refused ones, baler.migrations.hypertable.with_decompressed wraps
the statements. It decompresses the compressed chunks, runs the statements,
then compresses the same chunks again, all in the migration's one
transaction:
Code
- If anything fails, the table is exactly as it was.
- Every row comes back. Nothing is ever downsampled.
planlists the chunks and their total uncompressed size. That much disk must be free while the migration runs.- Measured on a 4-core dev machine: 3.0 M rows in 3 compressed chunks
(411 MB uncompressed) took 18.6 s to decompress, alter and recompress.
Time grows with the number of compressed rows. We haven't measured it on
nash-srv's hardware, so run
planthere and time a copy first (the restore drill gives you one). - A column that a continuous aggregate uses (
value,value_bool,ts,point_uri) can't change type at all while the aggregate exists. That migration has to drop and re-create the aggregate.
The upgrade-from-every-release test
tests/test_schema_upgrade.py (integration, run by CI's integration job)
gives each case its own scratch database on the stack's Postgres:
- Fresh: every migration applies,
planmatched what ran, and a secondupgradechanges nothing. - From every release: for the last release before migrations
(
PRE_MIGRATIONS_REF) and everyv*tag, it takes that release's source withgit archiveand builds a database with it in a subprocess. It then writes history, compresses the older chunks, and writes late into a compressed one. Then this code upgrades it. The test requires:- the schema equals a fresh database's (
schema.snapshot: tables, columns, constraints, indexes, hypertables, compression settings, aggregates and policies); - every table's rows, every
point_historyvalue and the set of compressed chunks are unchanged; - a second run changes nothing;
- this code can still write into a compressed chunk afterwards.
- the schema equals a fresh database's (
- Concurrent starters: six connections upgrading at once apply each migration once.
- Compressed chunks: a type change on
point_history, refused directly, goes throughwith_decompressedwith every row kept.
CI checks out with fetch-depth: 0 so the releases are there. There are no
v* tags yet. Each one you push is tested from then on, with no change to the
test. A release whose services build their schema differently would need its
own branch in the test's _BUILD script.
Upgrading Postgres or TimescaleDB
Both procedures below were rehearsed on 2026-11-08 against a database built by
this code: 2.0 M point_history rows (100 points at 1/min for 14 days), one
of three chunks compressed, with the aggregates and policies. Take a backup
first either way (see Backups).
TimescaleDB minor version (same Postgres major)
The timescale/timescaledb image ships many 2.x versions side by side, and
an existing database stays on the version it was created with until you
update it.
-
Change the image in
docker-compose.yml(keep the same-pg16suffix) and recreate only postgres:docker compose up -d --wait postgres. -
Update the extension. It must be the first statement of a fresh session, hence
psql -X:CodeUse your
BALER_PG_USER(and-dwith yourBALER_PG_DB) if they differ. -
docker compose up -d. Themigrateservice finds nothing to do.
Rehearsal: from 2.30.2 to 2.31.0 the UPDATE took 0.3 s. The row count,
an md5 over every row, the compressed chunks and the three policies were all
unchanged, and python -m baler.schema upgrade found nothing to do.
Postgres major version (e.g. 16 to 17)
A new Postgres major can't open the old data directory, so this is a dump and restore into a new volume, with the tools the restore drill already proves. The old volume stays as it was until you delete it, so going back means pointing at it again.
-
Back up the running stack:
python scripts/backup.py. -
Stop the stack:
docker compose down, without-v. -
Pick the new image with the same TimescaleDB version as the old one, e.g.
timescale/timescaledb:2.31.0-pg17.restore.pyneeds the dump's TimescaleDB version to be in the image (see Backups). Put it, and a new name for the Postgres volume, in a compose override, so only Postgres starts empty and every other volume is kept:CodeOn nash-srv, add it after
deploy/nash-srv/docker-compose.override.yml. Once it's done, move the new image intodocker-compose.ymland the volume name into nash-srv's override, so later deploys keep both. -
Start only postgres and oxigraph:
docker compose up -d --wait postgres oxigraph. Wait a few seconds more before restoring: the image's healthcheck can pass while its first-start init is still restarting the server, and a restore started in that window fails to connect. Rerunning it is safe. -
Restore:
python scripts/restore.py --postgres-file backups/postgres-<ts>.sql.gz --oxigraph-file backups/oxigraph-<ts>.ttl.gz -
Check it:
python -m baler.schema statusshould show the same migrations as before, andplannothing to do. -
docker compose up -d.
Rehearsal, 16.15 to 17.11 with TimescaleDB 2.31.0 on both:
| Step | Result |
|---|---|
| Backup | 12 s, 20 MB gzip'd |
| Restore | 11 s |
| Rows, md5 over every row | identical |
| Compressed chunks | identical (1 of 3) |
| Policies (compression, two refreshes) | present |
baler_schema_migrations | carried over, upgrade found nothing to do |
schema.snapshot, old vs new | identical |
| Hypertable size | 248 MB before, 189 MB after: the restore rewrote the uncompressed chunks without dead space |
Before a real upgrade, run the same steps on a copy. The rehearsal above did:
the source and the target were scratch compose projects
(COMPOSE_PROJECT_NAME=pgup16 and pgup17, each with its own
BALER_VOLUME_PREFIX, and scripts/restore-drill.compose.yml on top for
loopback ports), the target with the override above, and the backup came
from the source with scripts/backup.py. Both were removed with
docker compose down -v afterwards.

