Files
Beacon-Docs/docs/operations.md

1732 lines
92 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Day-to-day operations
Everything here assumes the all-in-one Docker stack. The commands translate to the split
deployment by running them on the server host.
## Checking it is healthy
Beacon has no health endpoint yet. Use these instead:
- `docker compose ps` shows every container `Up`. `db` and `redis` have health checks; `app`
waits for both before it starts.
- `curl -s https://<your-domain>/api/v1/brokers` lists each MQTT broker with its connection
status. A broker that never connects is a URL or credentials problem; see
[Getting packets in](getting-packets-in.md).
- Open `wss://<your-domain>/ws` in a WebSocket client. The first frame is `hello`.
- `docker compose logs -f app` shows a `connected` line per broker. Individual packets are
not logged at the default `info` level, so a quiet log after that is normal; use the
observers endpoint or the web app to see traffic.
## Logs
Logs go to stderr and Docker collects them. Docker's default `json-file` driver does not
rotate, so on a long-running host set a cap, either in `/etc/docker/daemon.json` for every
container or per service in `docker-compose.yml`:
```yaml
logging:
driver: json-file
options: { max-size: "50m", max-file: "5" }
```
Set `log.level` (`debug`, `info`,
`warn`, `error`; default `info`) and `log.format` (`text` or `json`) in `config.yaml`, or
override them with `LOG_LEVEL` and `LOG_FORMAT`. Invalid values stop startup.
Every record has a `component`. Ingest records add the broker name; HTTP completion records
add the validated client address, route, status and duration. Query strings and WebSocket
hello payloads are never logged. Expected ingest skips and routine WebSocket lifecycle events
are at debug level.
```bash
docker compose logs -f app # the API and ingest
docker compose logs -f caddy # the proxy
```
Caddy also writes a JSON access log to `data/Caddy/logs/access.json`, which the fail2ban rules
in [`app_config/fail2ban/`](../app_config/fail2ban/README.md) read.
## Where the data lives
Everything persistent is under `data/` next to `docker-compose.yml`:
- `data/postgres/` is the database. Deleting it deletes all history.
- `data/redis/` is the cache. It can be removed while the stack is down; Beacon rebuilds it.
- `data/Caddy/data/` holds TLS certificates. Deleting it means a new certificate request on
the next start, which counts against Let's Encrypt rate limits.
- `data/app/config.yaml` and `.env` are your configuration. Keep copies of both somewhere
else; no backup below includes `.env`.
## Retention
Everything below is a duration in `config.yaml`. Shorten them if disk is tight, lengthen them
if you want more history.
| Setting | Default | What it covers |
|---|---|---|
| `packets.retention` | `168h` (7 days) | Packets, observations and channel messages |
| `analytics.rollup_retention` | `2160h` (90 days) | Hourly stats rollups. Also the longest window the stats endpoints accept |
| `telemetry.retention` | `744h` (31 days) | Observer telemetry snapshots |
| `routes.retention` | `336h` (14 days) | Known routes, counted from when a route was last seen |
| `nodes.delete_after` | `720h` (30 days) | Nodes not heard from |
| `observers.delete_after` | off | Observers not heard from. Opt in by setting a duration |
Historical stats read the hourly rollups, not raw packets. Each UTC hour is rolled about 95
minutes after it closes, and packet cleanup holds back up to 24 hours of raw rows until their
hours are rolled, so raw data lives a little longer than `packets.retention` says. An hour
whose raw rows were deleted first is reported as partial, with no values.
## Backing up
Two options.
**A plain `pg_dump`** from the host, which is enough for most operators:
```bash
docker compose exec -T db pg_dump -U beacon -d beacon -Fc > beacon-$(date +%F).dump
```
Keep `data/app/config.yaml` and `.env` with it. The dump holds message data and anything you
have decrypted, so treat it as private.
**The admin backup endpoint** produces an archive of the database and the saved
`config.yaml`, with a manifest and checksums, and a command-line tool that verifies it. It is
off by default and needs the admin key. See [Backup and export](backup-export.md).
## Restoring
Restore into an empty database, never over live data. With the stack running and the app
stopped, drop and recreate the database, load the dump, and only then start the app:
```bash
docker compose stop app
docker compose exec -T db psql -U beacon -d postgres -c 'DROP DATABASE beacon;' -c 'CREATE DATABASE beacon;' \
&& docker compose exec -T db pg_restore -U beacon -d beacon < beacon-YYYY-MM-DD.dump \
&& docker compose start app
```
If `pg_restore` reports errors the app is not started; read them before trying again.
A 1.x dump cannot be restored into 2.0.0; the server refuses the old schema. See
[Upgrading](upgrading.md#upgrading-to-200-from-1x).
## Before deploying a release
Migrations run when the server starts, and some need a lock on a busy table. Check two things
in `psql` before you pull a new image:
```bash
docker compose exec db psql -U beacon -d beacon
```
```sql
-- No transaction older than five minutes. This must return no rows.
SELECT pid, now()-xact_start, state, left(query,80) FROM pg_stat_activity WHERE xact_start < now() - interval '5 minutes';
-- No anti-wraparound autovacuum on the tables the release touches.
SELECT pid, now()-xact_start, query FROM pg_stat_activity WHERE query LIKE 'autovacuum:%to prevent wraparound%';
```
A long transaction holds back the locks a migration waits for. An anti-wraparound autovacuum
does not give way to a lock request the way an ordinary autovacuum does, so a migration on
that table waits until the vacuum ends. Let either finish, or end a stuck client session with
`SELECT pg_cancel_backend(<pid>);`, then deploy.
If you run the [daily plan check](#turning-on-the-daily-check), also confirm its report is clean
before and after the deploy. Maintainers run the plan checks before every server release; see
[Before a release](#before-a-release).
### How startup migrations wait for locks
Each migration statement runs with `lock_timeout = 30s`. When a statement times out on a lock,
the server logs a `migration lock wait timed out` warning listing the sessions that blocked it
(pid, state, `xact_start` and the first 200 characters of the query), waits, and tries the
file again: three attempts in all, 10 s after the first and 20 s after the second. A file
still blocked after about two minutes stops startup with `migrations failed`, the server exits
with status 1, and Docker restarts it (`restart: unless-stopped`), which starts the same check
again. A restart loop with these warnings means a session in the blocker list is holding the
lock; deal with it and the next start goes through.
Retrying is safe. A file without `CONCURRENTLY` runs in one transaction and rolls back whole.
An interrupted `CREATE INDEX CONCURRENTLY` leaves an invalid index, which the server drops and
rebuilds on the next attempt. An interrupted `DROP INDEX CONCURRENTLY` is finished by the
`IF EXISTS` drop on the next attempt.
Migration `004_drop_known_routes_iata_last_seen.sql` drops `idx_known_routes_iata_last_seen`,
which no query reads and which can reach about 10 GB on a large deployment. If you already
dropped it by hand (see below), the migration does nothing.
Migrations `011` to `024` (the small-table release) are one statement per file: one small
`CREATE INDEX CONCURRENTLY` on `node_iatas`, eleven small index drops on `node_iatas`, `nodes`,
`observers` and `channels`, one rename, and `fillfactor = 50` on `observers` and `channels`.
Together they take seconds at boot. Each drop needs a brief lock on its table, so run the
check above first and don't start an external `pg_dump` or a long transaction against those
four tables during the upgrade; a blocked drop stops startup after about two minutes as
described below, and the next start resumes where it stopped. Afterwards, compact the two
tables once; see [Compacting observers and channels](#after-upgrading-compacting-observers-and-channels).
Migrations `025` to `033` (2.1.0) change `packets`:
- `025` and `027` add the checks `packets_payload_type_domain` (`payload_type` 0 to 15) and
`packets_route_type_domain` (`route_type` 0 to 3) without checking existing rows. Each needs
a brief lock on `packets`.
- `026` and `028` validate them. Each scans `packets`; writes continue meanwhile.
- `029` to `032` build four indexes with `CREATE INDEX CONCURRENTLY`; see
[Pre-building the packets indexes](#pre-building-the-packets-indexes).
- `033` creates the statistics object `packets_heard_inverted_stat` and runs `ANALYZE packets`.
Ingest only stores types decoded from the packet header, which always fit, so only rows
changed by hand can fail the checks. Before upgrading, confirm none exist; when none do, this
reads the whole table, so run it off-peak:
```sql
SELECT EXISTS (SELECT 1 FROM packets WHERE payload_type NOT BETWEEN 0 AND 15 OR route_type NOT BETWEEN 0 AND 3);
```
It must return `f`. Otherwise `026` or `028` fails and stops startup on every attempt; fix or
delete those rows first.
Migration `034_observations_adverts.sql` builds the partial index `idx_observations_adverts` on
`packet_observations` with `CREATE INDEX CONCURRENTLY`; see
[Pre-building the observer adverts index](#pre-building-the-observer-adverts-index). The same
release starts a background job that fills in missing observation payload types; see
[After upgrading: observation payload types](#after-upgrading-observation-payload-types).
Migrations `035` to `041` add the channel message read model; see
[Read models: channel messages by site](#read-models-channel-messages-by-site).
- `035` creates the table `channel_message_iatas`.
- `036` to `040` build its five indexes with `CREATE INDEX CONCURRENTLY` on the empty table,
which is instant.
- `041` builds `idx_channel_messages_channel_id` on `channel_messages` with
`CREATE INDEX CONCURRENTLY`. `channel_messages` is small (about 200,000 rows on a large
deployment), so it takes well under a second and needs no pre-build.
To pre-build `041` anyway, run the pre-deploy check above, then this text exactly, on its own
in autocommit mode:
```sql
CREATE INDEX CONCURRENTLY idx_channel_messages_channel_id ON channel_messages USING btree (channel_id, id DESC);
SELECT indisvalid AND indisready FROM pg_index WHERE indexrelid = 'idx_channel_messages_channel_id'::regclass;
```
The second statement must print `t`. The migration then records the existing valid index
instead of building it again. After the deploy, check every new index:
```sql
SELECT c.relname, i.indisvalid AND i.indisready FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname IN ('channel_message_iatas_pkey', 'idx_cmi_channel_site', 'idx_cmi_site', 'idx_cmi_channel_site_scope',
'idx_cmi_channel_scope', 'idx_cmi_scope', 'idx_channel_messages_channel_id');
```
It must return 7 rows, all `t`.
Migrations `042` to `045` add the region packet read model; see
[Read models: packets by site](#read-models-packets-by-site).
- `042` creates the table `packet_sites`. Its foreign key to `packets` takes a brief lock on
`packets`, so run the pre-deploy check above first.
- `043` to `045` build `idx_packet_sites_site`, `idx_packet_sites_site_type` and
`idx_packet_sites_site_scope` with `CREATE INDEX CONCURRENTLY` on the empty table, which
takes seconds. No pre-build is needed.
After the deploy, check the table's four indexes:
```sql
SELECT c.relname, i.indisvalid AND i.indisready AS ok
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'packet_sites'::regclass ORDER BY 1;
```
It must return 4 rows, all `ok = t`.
Migrations `047` to `053` finish the cleanup; see
[Lookup-table indexes and the route stamp column](#lookup-table-indexes-and-the-route-stamp-column).
- `047` to `052` drop six cold indexes on small lookup tables with
`DROP INDEX CONCURRENTLY IF EXISTS`: milliseconds each, plus any wait for a lock.
- `053` drops the column `known_routes.last_reconfirmed_at` in a transaction. It is a catalog
change, not a rewrite (179 ms on a 10.5-million-row copy), but it takes an `ACCESS EXCLUSIVE`
lock on `known_routes`, so run the pre-deploy check above, and make sure no `pg_dump` is
running: the drop waits for it. While it waits (up to 30 s, three attempts), every other
session's reads of `known_routes`, read-only servers' included, queue behind it. The
column's space (about 8 bytes a route, some 80 MB on 10.5 million routes) is reclaimed only
when the table is next rewritten (`VACUUM FULL` or `pg_repack`); new and updated rows no longer
carry it. **It is a one-way door for older images**; see
[Upgrading](upgrading.md).
### Pre-building the route hop index
Migration `006_known_routes_hop_iata.sql` builds `idx_known_routes_hop_iata`, which `/routes`
uses when a request filters by `hopCount`. The server builds it with `CREATE INDEX
CONCURRENTLY` during startup and does not serve the API until it finishes. On a large
`known_routes` table (tens of millions of rows) that takes 10 to 25 minutes and needs 0.2 to
0.4 GB of disk. To keep that out of the upgrade, build it yourself off-peak before deploying,
in `psql` as above:
```sql
CREATE INDEX CONCURRENTLY idx_known_routes_hop_iata ON known_routes (hop_count, iata);
SELECT indisvalid, indisready FROM pg_index WHERE indexrelid = 'idx_known_routes_hop_iata'::regclass;
```
Run the `CREATE` on its own in autocommit mode, not inside a transaction block. The check must
return `t | t`. Anything else means the build was interrupted and left an invalid index; drop
it and build again:
```sql
DROP INDEX CONCURRENTLY idx_known_routes_hop_iata;
CREATE INDEX CONCURRENTLY idx_known_routes_hop_iata ON known_routes (hop_count, iata);
```
When the server starts and finds a valid index with that name, it records the migration as
applied without building anything. If you skip this step, the migration still works; startup
just takes longer. Run the pre-deploy check above before either.
### Pre-building the packets indexes
Migrations `029` to `032` build four indexes on `packets` with `CREATE INDEX CONCURRENTLY`
while the API and ingest wait. On a small database that is seconds. On a large one, build them
yourself off-peak before deploying, in `psql` as above, each statement on its own in
autocommit mode. Use exactly these names and definitions:
```sql
CREATE INDEX CONCURRENTLY idx_packets_last_heard_hash ON packets USING btree (last_heard_at DESC, packet_hash DESC);
CREATE INDEX CONCURRENTLY idx_packets_type_last_heard ON packets USING btree (payload_type, route_type, last_heard_at DESC, packet_hash DESC);
CREATE INDEX CONCURRENTLY idx_packets_scope_last_heard ON packets USING btree (scope_id, last_heard_at DESC, packet_hash DESC) INCLUDE (payload_type, route_type, first_heard_at) WHERE (scope_id IS NOT NULL);
CREATE INDEX CONCURRENTLY idx_packets_heard_inverted ON packets USING btree (first_heard_at) INCLUDE (last_heard_at, packet_hash, payload_type, route_type, scope_id) WHERE ((first_heard_at > last_heard_at) IS TRUE);
```
Then check them. Every row must read `t | t`:
```sql
SELECT c.relname, i.indisvalid, i.indisready, pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname IN ('idx_packets_last_heard_hash', 'idx_packets_type_last_heard',
'idx_packets_scope_last_heard', 'idx_packets_heard_inverted');
```
An invalid index means the build was interrupted; drop it with `DROP INDEX CONCURRENTLY` and
build it again. When the server finds a valid index with one of these names, it records that
migration as applied without building anything. It checks only the name, so a different
definition under the same name is kept as it is.
Pre-build only these indexes. Don't create the two checks or `packets_heard_inverted_stat`
yourself: migrations `025`, `027` and `033` fail on an object that already exists, and
startup stops.
To go back to the previous server image, deploy it. It ignores the new indexes, checks and
statistics. Leave them in place: their migrations stay recorded, so a later upgrade would not
build them again.
### Pre-building the observer adverts index
Migration `034` builds `idx_observations_adverts`, which `/observers/{observerId}/adverts` reads
once the [payload-type backfill](#after-upgrading-observation-payload-types) is done. The
server builds it at startup while the API and ingest wait. On a large `packet_observations`
table (hundreds of millions of rows) that should take a minute or two and about 150 MB of
disk, so build it yourself off-peak before deploying. Run the
[pre-deploy check](#before-deploying-a-release) first: the long-transaction query must return
no rows. Then, in `psql` as above, on its own in autocommit mode, run exactly this text, byte
for byte the same as the migration:
```sql
CREATE INDEX CONCURRENTLY idx_observations_adverts ON packet_observations (observer_id, id) WHERE payload_type = 4;
```
Then check it:
```sql
SELECT indisvalid, indisready, pg_get_indexdef(indexrelid), pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_index WHERE indexrelid = 'idx_observations_adverts'::regclass;
```
It must read `t | t`, with the definition
`CREATE INDEX idx_observations_adverts ON public.packet_observations USING btree (observer_id, id) WHERE (payload_type = 4)`.
If `indisvalid` is `f`, the build was interrupted; drop the index and build it again:
```sql
DROP INDEX CONCURRENTLY idx_observations_adverts;
```
When the server starts and finds a valid index with that name, it records migration `034` as
applied without building anything, so startup is instant. If you skip this step, the same
build runs at startup instead, and the API and ingest wait for it.
Turn on the [daily plan check](#turning-on-the-daily-check) only after migration `034` is
recorded. Some of its adverts checks need this index; without it they take 1 to 16 seconds
instead of 78 ms. To confirm:
```sql
SELECT filename FROM schema_migrations WHERE filename = '034_observations_adverts.sql';
```
To go back to the previous server image, deploy it. The index stays and nothing reads it: about
150 MB, plus one entry for each advert observation stored. You can leave it. If you drop it
(`DROP INDEX CONCURRENTLY idx_observations_adverts;`), also delete its migration record, so a
later upgrade builds it again:
```sql
DELETE FROM schema_migrations WHERE filename = '034_observations_adverts.sql';
```
### After upgrading: route maintenance
The release that adds migration 006 changes how known routes are cleaned up. Deploy it
off-peak and expect the following.
- Route retention runs every `background.reconfirm` (default `1h`). Its first run after the
upgrade is a full pass over the grace window (routes under `routes.min_observations` older
than `routes.grace`). On a table of about 30 million routes that takes 12 to 14 minutes, in
statements of a minute or less. The clock hour of that first run becomes the hour of a weekly
full pass; a change to `routes.grace` or `routes.min_observations` also triggers one. Other
runs only cover what aged out since the previous run.
- Stale neighbor edges are cleared at the end of every retention run. The first run may
delete a backlog; later runs take a few seconds.
- The route recheck (routes with a hop that has departed or become ambiguous) runs every 5
minutes and works through the table at `routes.reconfirm_rows_per_hour` (default `81000`). A
full cycle takes the route count divided by that rate: about 16 days for 32.5 million routes.
The first cycle reaches routes older releases never got to, and deletes the ones the same
rule already condemned, typically a few percent of the table. About half of those come back
the next time they are heard.
### After upgrading: observation payload types
Observations stored by servers older than the observation `payload_type` column have no payload
type of their own. The release with migration `034` adds a background job, on the `maint` pool,
that gives each of them its own packet's payload type. It walks `packet_observations` by id in
spans of one million ids, up to ten spans a minute, so at most about 10 million ids a minute;
after each span it pauses as [`readmodels.fill`](#pacing-the-backfills) sets, by default as
long as the span took, so expect about half that. Each statement
writes at most 50,000 rows, and the job saves its position after every span, so a restart
resumes where it stopped. When the walk passes the highest id it read at the start of its run,
it records `done`. Rows stored later are typed by ingest.
How long it takes depends on the id range, not on how many rows need a type: a database whose
ids run to about 830 million needed about 80 minutes before the pacing, so expect up to twice
that at the default `duty: 0.5`. On a database whose observations are all
typed, each statement reads its span and updates nothing.
Until a server process reads `done`, `/observers/{observerId}/adverts` answers `503` with
`Retry-After: 30` (the statement that served those pages before `done` is gone). The job logs
`observation payload type backfill progress: observer advert pages answer 503 until it is done`
with `next_id`, `max_id`, `ids_done`, `ids_remaining` and `eta` once a minute while it walks. Once a server process reads `done`,
it serves the pages from `idx_observations_adverts` and keeps that until restart. On a database
whose observations already all carry a payload type (every deployment that started on a release
with the column), the job's first run finds no untyped row and records `done` at once
(`observation payload types complete ... probe=true`), so the pages are back within a minute of
boot. There is no switch for this gate.
Check progress, read-only:
```sql
SELECT value, updated_at FROM background_state WHERE key = 'observation_payload_type';
```
`next_id` climbs about 10 million a minute, and the value ends as `{"next_id": ..., "done": true}`.
**Marking it done by hand.** You can skip the walk on a database that has no untyped
observations. First check, read-only. This reads all of `packet_observations` once, so run it
off-peak:
```sql
BEGIN READ ONLY;
SET LOCAL statement_timeout = '10min';
SELECT EXISTS (SELECT 1 FROM packet_observations WHERE payload_type IS NULL) AS any_untyped;
ROLLBACK;
```
Only if it prints `f`, and after the job's first run has created the key (the progress query
returns a row), run:
```sql
UPDATE background_state SET value = '{"next_id": 0, "done": true}'::jsonb, updated_at = now()
WHERE key = 'observation_payload_type';
```
It should report `UPDATE 1`. A run that was already in flight can write `"done": false` once
more, so run the progress query again two minutes later and repeat the `UPDATE` if needed. Each
server process picks up `done` on its next adverts request; no restart is needed.
On a deployment that had untyped observations, the backfill corrects two things: the
per-observer payload breakdown's unknown (`-1`) bucket shrinks into the real types, and route
evidence starts to include those rows.
Don't upgrade to a release that drops `idx_observations_observer_id` until the progress query
shows `"done": true`.
Going back to the previous server image needs nothing here. Older servers ignore the
`observation_payload_type` key, and rows the job typed stay typed, which older servers read the
same way.
### After upgrading: stats and traces on the custom pool
In the release with migration `034`, five statements move from the `api_legacy` pool to
`api_custom`, under both `custom_all` and `split`: `GetTopNodes`, `GetStatsTopAdvertisers`,
`GetPathStats`, `GetStatsSeries` and `ListTraceTags`. That pool has a 35 s statement timeout,
`jit=off` and no parallel workers (see [Database pools and connections](#database-pools-and-connections)).
They are also guarded, but a guard's deadline applies only if its family is listed in
`guards.enforce`, which is empty by default.
For top nodes, top advertisers and paths, requests with at most 32 distinct IATAs and
whole-hour bounds use new statements that read the hourly rollups one hour and IATA at a time.
Only wider lists, such as every IATA at once, still run the previous statements, now without
parallel workers. Measured over all 625 IATAs for 7 days on a copy of a large production
database, they stay far inside the timeout:
| Statement | With parallel workers | Without |
|---|---:|---:|
| top nodes | 276 ms | 556 ms |
| top advertisers | 1.47 s | 3.63 s |
| paths | 1.63 s | 1.58 s |
Responses are unchanged. Under `split`, `ListObserverAdverts` and `GetObserverTelemetrySince`
run on cached generic plans; see [Configuration](configuration.md#database-pools-and-cost-guards).
### After upgrading: compacting observers and channels
The small-table release (migrations `011` to `024`) drops the indexes that made every observer
presence update and every channel hearing rewrite index entries, and sets `fillfactor = 50` on
`observers` and `channels` so those updates stay on the same page (HOT updates). The fillfactor
only applies to pages written from then on. A table that bloated before the upgrade keeps its
size until it is rewritten once: on a large deployment `observers` held about 4,000 rows in
52 MB, and about 6 MB after the rewrite.
**When.** Once, off-peak, after the release is live and its last migration is recorded. This
must return `1`:
```sql
SELECT count(*) FROM schema_migrations WHERE filename LIKE '024_%';
```
Not before: without the fillfactor and the dropped indexes, the tables bloat again.
**Is it worth it?** Check the sizes in `psql` as above:
```sql
SELECT relname, relpages, pg_size_pretty(pg_relation_size(oid)) AS heap, reltuples::bigint AS rows
FROM pg_class WHERE relname IN ('observers', 'channels');
```
If both are already a few MB, skip it.
**Run** as the table owner (the `beacon` user) or a superuser, each statement on its own in
autocommit mode, not inside a transaction block:
```sql
SET lock_timeout = '3s';
VACUUM FULL observers;
VACUUM FULL channels;
```
- Each `VACUUM FULL` rewrites the table and its indexes under an `ACCESS EXCLUSIVE` lock, which
blocks reads and writes on that table while it runs. On tables of this size that is a few
seconds, so ingest's observer and channel updates and the observer and channel API pages
wait that long.
- If it reports `canceling statement due to lock timeout`, another session held the table;
run the same statement again.
- It needs free disk equal to the table's new size plus its indexes, a few MB here. The old
files are freed when it finishes.
**Verify.** Re-run the size query: `relpages` and the heap size should drop. With fillfactor
50 a table is about twice its packed size, so `observers` ends near 6 MB for 4,000 rows. Then
check that updates are now HOT. The counters are cumulative, so take two samples an hour or so
apart and compare the change:
```sql
SELECT relname, n_tup_upd, n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_pct
FROM pg_stat_user_tables WHERE relname IN ('observers', 'channels');
```
Between the two samples, nearly all new updates (`n_tup_hot_upd` growth over `n_tup_upd`
growth) should be HOT; before this release only a few percent were. Sessions holding long
snapshots lower it somewhat.
**Rollback.** None needed for the rewrite; it changes no data.
To go back to the previous server image, just deploy it: it works without the dropped
indexes, but type-filtered node pages (`/nodes` with a node type) are slower until the indexes
are rebuilt. It ignores the channel id cache and goes back to using a sequence value per
channel hearing. If you want the indexes back, rebuild them with `CREATE INDEX CONCURRENTLY`
using the definitions in the server's `db/migrations/001_baseline.sql`, for example:
```sql
CREATE INDEX CONCURRENTLY idx_nodes_type_last_seen ON nodes USING btree (node_type, last_seen DESC);
```
The baseline's `idx_node_iatas_iata` on `(iata, last_heard DESC)` has been replaced by an index
of the same name on `(iata)`; leave it in place.
## Read models: channel messages by site
`channel_message_iatas` is a small table with one row for each channel message and each site
that heard it. Each row also copies the message's channel and its packet's scope. Ingest keeps
it up to date, and a background reconcile repairs it every 5 minutes. On a large deployment it
takes about 120-150 MB with its five indexes.
While the server process trusts the table, these channel message pages read it:
- `/channels/{id}/messages` with `iatas`, with `iatas` and `scope`, with `scope` alone, and
with `since` and `iatas`;
- `/messages` with `iatas`, and with `scope` alone.
Each such page reads about as many index entries as it returns, where the previous
statement read every observation of the channel. While the process does not trust the table,
those pages answer `503` with `Retry-After: 30`. Two shapes always use the previous all-channel
statement, trusted or not, on the two-connection `api_slow` pool: `/messages` with `iatas` and
`since`, and `/messages` with `iatas` and `scope`. Pages without `iatas` or `scope` never read
the table. Responses are the same as the previous statements' in every case.
### Installing it
The release adds migrations `035` to `041`; see [Before deploying a release](#before-deploying-a-release).
On the first start the server fills the table in the background:
1. It waits until every transaction that started before boot and holds a transaction id has
finished, then runs one full reconcile.
2. It backfills the table oldest message first, in batches of 10,000 message ids
(`readmodels.fill.cmi_batch`), pausing after each batch as
[`readmodels.fill`](#pacing-the-backfills) sets: at least 0.1 s, and by default as long as
the batch took. Once a minute the log shows a `cmi backfill progress` line with `ids_done`,
`ids_remaining` and `eta`, then `cmi backfill complete`.
On a deployment with about 200,000 messages this takes 3 to 5 minutes, mostly the pauses.
3. It runs a one-time verification: an exact check of two ranges of 2,000 messages, and a
page-by-page comparison of the previous and the new statements on the busiest recent sites
and scopes. On success it logs `cmi first-time verification passed` and records the time,
so later starts skip it.
4. It logs `cmi trusted: region and scope message pages read channel_message_iatas`.
Until step 4, the pages above answer `503` with `Retry-After: 30`; the server logs
`cmi boot repair starting: region and scope message pages answer 503 until it is trusted` when
it begins. If the verification fails, the server logs
`cmi verification failed; region and scope message pages answer 503 until a later pass succeeds`
and tries again every 5 minutes. See [Pages that wait for a read model](#pages-that-wait-for-a-read-model)
for what users see.
Check progress, read-only:
```sql
SELECT value, updated_at FROM background_state WHERE key = 'cmi';
SELECT pg_size_pretty(pg_total_relation_size('channel_message_iatas'));
```
`complete` turns `true` within minutes, then `first_verified_at` appears. After that,
`updated_at` advances every 5 minutes.
### When pages use it
Trust is held in memory by each server process; nothing in the database marks the table as
exact. A process earns it at every start, once the boot reconcile has run, the backfill is
complete and the one-time verification has passed. It drops trust:
- after 3 reconciles in a row that each repaired at least one row. It logs
`read model untrusted: repair runs fixed rows the writers missed`. Two clean reconciles in a
row restore it;
- at once when ingest leaves a message without its sites: a failed site insert, a message
stored without its sites, or any message insert error. It logs
`read model untrusted until the next reconcile` with the reason. Trust returns after the
next reconcile that started after the failure, so those pages answer `503` for at most about
one interval;
- when the stored state is no longer complete and verified, for example after a rebuild.
Each restore after a drop logs `read model trusted again: its pages are served from it`.
A reconcile that repairs rows logs `cmi reconcile repaired N pair(s): a writer bypassed the read model`.
Something stored messages without recording their sites. On a single server running this
release that should not happen. Running two server versions that both write to one database at
the same time is not supported; if it happens, the pairs the older one leaves out are repaired
within one reconcile interval.
A [read-only server](configuration.md#read-only-mode) cannot repair anything. Every 5 minutes it
checks, read-only, that the writing server's saved state is complete and verified, that the
writer saved it within the last 10 minutes (two reconcile intervals, on the database clock), and
that the newest 2,000 messages pass the exact check. While all three hold it logs
`cmi trusted on this read-only server: region and scope message pages read channel_message_iatas`
and serves those pages from the table. Its trust ends on its own 10 minutes after the writer's
last save, unless a later check finds a newer one. A writer that is rolled back, stopped or keeps
failing stops saving, so on read-only servers those pages answer `503` within 10 minutes, and
they log `cmi not trusted on this read-only server: the writer has not saved its state recently`.
### Turning it off
Set `readmodels.cmi.enabled: false` in `config.yaml` and restart; see
[Configuration](configuration.md#read-models). This turns the pages that read the table off:
they answer `503` until the key is `true` again. Every other channel message page is
unaffected. The table is still maintained, so turning it back on needs only a restart.
### Metrics and alerts
On the [admin listener](#metrics):
- `beacon_readmodel_trusted{model="cmi"}` is 1 while this process serves pages from the table.
- `beacon_readmodel_repaired_rows_total{model="cmi"}` should stay flat.
- `beacon_readmodel_watermark_age_seconds{model="cmi"}` is the time since the last finished
reconcile, or on a read-only server since the writer last saved its state. It stays under
600 s.
Suggested rules are in [Example alert rules](#example-alert-rules): repaired rows in 3
consecutive 5-minute windows, trusted at 0 for more than 15 minutes (those pages are answering
`503`), and a watermark older than 15 minutes.
### Rolling back
Deploy the previous image. It ignores `channel_message_iatas`, and the table's rows are deleted
with their messages, so it does not grow stale rows. Leave the table and its indexes in place.
To roll forward again later, deploy the new image. Before it serves any page from the table,
the boot reconcile adds every site the older image stored, starting from the saved watermarks.
Those pages answer `503` meanwhile: about a second after a short rollback, a few minutes after
days. Nothing to do by hand.
Rebuild the table from scratch only if the verification keeps failing:
```sql
DELETE FROM background_state WHERE key = 'cmi';
TRUNCATE channel_message_iatas;
```
Then restart the server. It backfills and verifies again as on the first install.
A later release will drop `idx_channel_messages_channel` and `idx_channel_messages_sent_brin`.
Run this release for at least a week before upgrading to that one.
## Read models: packets by site
`packet_sites` holds one row for each packet and each site (IATA) that heard it, at that site's
latest hearing. Each row also copies the packet's payload type, route type, scope and first
hearing. The observation insert writes the row in the same statement, so a stored observation
always has its row. A background reconcile repairs the newest rows every 5 minutes, an hourly
audit refills one random older slice, and a front sweep every 30 seconds keeps the newest
index entries dense. On a large deployment (about 7.4 million rows) it takes about 1.6 GB:
heap 484 MB, primary key 285 MB, `idx_packet_sites_site` 419 MB, `idx_packet_sites_site_type`
353 MB, `idx_packet_sites_site_scope` 53 MB.
While the server process trusts the table, `/packets` pages with `iatas`, `iata`, `region` or
`regionId` (region pages) read it:
- one site: one range of the site's index;
- several sites: one range per site;
- payload and route types, up to 8 (payload, route) pairs: one exact range per site and pair;
- a `since`/`until` window, scopes, or more than 8 type pairs: bounded reads of each site range
that continue, within the same request, until the page is full. If 8 reads do not fill the
page, the previous release's statement answers that request, on the two-connection
`api_slow` pool with a 120 s statement limit (see [Slow region and message statements](#slow-region-and-message-statements)).
Unknown site codes are dropped by a cheap check first. Requests with `hashPrefix` never read
the table. A region page served from the table now returns `nextPageCursor`; see
[Exact page cursors](api-contract.md#exact-page-cursors). While the process does not trust the
table, region pages answer `503` with `Retry-After: 30`.
Each page is the previous statement's page, except where that statement loses rows: when a
site's packets are heard by many observers, it can end a page short, end a walk early (an
empty page with `hasMore: false`) or jump `nextCursor` past rows. The new page is full and
skips nothing. Packets tied on their site time come out by packet hash, descending.
On a production-size copy, the heaviest region pages went from seconds to under a second: all
561 sites for the last hour from 19.5 s to 0.3 s, and the 13 busiest sites for the last 75 minutes at
limit 200 from 4.5 s to 31 ms. A filter of rare types on a very sparse site can read a few
buffers more than before. An `until` well in the past often runs out of the read budget, so
the previous statement answers many of those pages, at the previous cost.
### Ingest cost
The site row makes each observation insert about 15-21% slower (mean 104 µs to 119-125 µs)
and adds about 37-53% WAL per stored observation (1,425-1,470 B to 2,008-2,188 B), measured
over 100,000 inserts on a scratch database. Re-sends of an observation still write nothing.
After the deploy, compare `InsertObservation` in `pg_stat_statements` (`mean_exec_time`, and
`wal_bytes / calls`) for the hour before and the hour after, and watch
`beacon_ingest_queue_depth`. `pg_stat_statements` is cumulative, so take this snapshot an hour
before the deploy, at the deploy and an hour after, and divide the differences:
```sql
SELECT now(), sum(calls) AS calls, sum(total_exec_time) AS exec_ms, sum(wal_bytes) AS wal_bytes
FROM pg_stat_statements WHERE query LIKE '-- name: InsertObservation :one%';
```
A growing ingest queue, or WAL beyond what you planned for, means rolling back.
### Installing it
The release adds migrations `042` to `045`; see [Before deploying a release](#before-deploying-a-release).
On the first start the server fills the table in the background:
1. It waits until every transaction that started before boot and holds a transaction id has
finished (`packet_sites boot repair waits for transactions from before boot` while it
waits), then runs one reconcile from the saved watermark.
2. It backfills the table newest observation first, in slices of 500,000 observation ids
(`readmodels.fill.packet_sites_slice`), pausing after each slice as
[`readmodels.fill`](#pacing-the-backfills) sets: at least 0.1 s, and by default as long as
the slice took, so the fill is busy at most half the time. Once a minute the log shows a
`packet_sites backfill progress` line with `ids_done`, `ids_remaining` and `eta`, then
`packet_sites backfill complete`. On a large deployment (about 766 million ids, 29.4 million
observations) that is about 1,530 slices; expect roughly twice the fill work, about 10 to 40
minutes, longer on a slow disk or a lower `duty`, and a few GB of WAL.
3. It runs a one-time verification. It first runs `VACUUM (ANALYZE) packet_sites` (a few
minutes on a large deployment), then an exact check of two slices of 500,000 observation
ids, the newest and one random older one, and a page-by-page comparison of region pages of
the busiest recent sites with pages read straight from the observations. On success it logs
`packet_sites first-time verification passed` and records the time, so later starts skip it.
4. It logs `packet_sites trusted: region packet pages read packet_sites`.
Until step 4, region pages answer `503` with `Retry-After: 30`; the server logs
`packet_sites boot repair starting: region packet pages answer 503 until it is trusted` when it
begins. If the verification fails, the server logs
`packet_sites verification failed; region packet pages answer 503 until a later pass succeeds`
and tries again every 5 minutes. See [Pages that wait for a read model](#pages-that-wait-for-a-read-model).
Check progress, read-only:
```sql
SELECT value, updated_at FROM background_state WHERE key = 'packet_sites';
SELECT pg_size_pretty(pg_total_relation_size('packet_sites'));
```
`complete` turns `true`, then `first_verified_at` appears. After that, `updated_at` advances
every 5 minutes.
### When pages use it
Trust is held in memory by each server process; nothing in the database marks the table as
exact. A process earns it at every start, once the boot reconcile has run, the backfill is
complete and the one-time verification has passed. It drops trust:
- at once when a reconcile or the hourly audit repairs any row: something bypassed the
writer. Two clean reconciles in a row restore it. A repair logs
`packet_sites reconcile repaired N row(s): a writer bypassed the read model` or
`packet_sites audit repaired N row(s) below the watermark`, and the drop logs
`read model untrusted: repair runs fixed rows the writers missed`;
- at once when an observation insert with its site row fails. Ingest never loses the
observation: it logs `observation site row failed; storing the observation without it` and
stores it without its row. Trust drops with
`read model untrusted until the next reconcile` and the reason, and returns after the next
reconcile that started after the failure, so region pages answer `503` for at most about one
interval;
- after 3 failed reconciles in a row, until one succeeds;
- when the saved state is no longer complete and verified, for example after a rebuild.
Each restore after a drop logs `read model trusted again: its pages are served from it`.
On a single server running this release, repairs should not happen. Running two server versions
that both write to one database at the same time is not supported; if it happens, the rows the
older one leaves out are repaired within one reconcile interval, and trust returns two clean
reconciles later.
A [read-only server](configuration.md#read-only-mode) cannot repair anything. Every 5 minutes it
checks, read-only, that the writing server's saved state is complete and verified, that the
writer saved it within the last 10 minutes (two reconcile intervals), and that the newest
20,000 observations pass the exact check. While all three hold it logs
`packet_sites trusted on this read-only server: region packet pages read packet_sites` and
serves region pages from the table. Its trust ends on its own 10 minutes after the writer's last
save, unless a later check finds a newer one. Otherwise it logs one of:
- `packet_sites not trusted on this read-only server: backfill or first verification not done`
- `packet_sites not trusted on this read-only server: the writer has not saved its state recently`
- `packet_sites not trusted on this read-only server: the newest observations fail the exact check`
### Turning it off
Set `readmodels.packet_sites.enabled: false` in `config.yaml` and restart; see
[Configuration](configuration.md#read-models). This turns region packet pages off: they answer
`503` until the key is `true` again. The table is still maintained and swept, so turning it back
on needs only a restart.
### Metrics and alerts
On the [admin listener](#metrics):
- `beacon_readmodel_trusted{model="packet_sites"}` is 1 while this process serves region pages
from the table.
- `beacon_readmodel_repaired_rows_total{model="packet_sites"}` should stay flat.
- `beacon_readmodel_watermark_age_seconds{model="packet_sites"}` is the time since the last
finished reconcile, or on a read-only server since the writer last saved its state.
- `beacon_packet_sites_sweep_age_seconds` is the time since the last successful front sweep,
and `beacon_packet_sites_sweep_plan_ok` is 1 while the sweep plans as index scans on the three
time-ordered indexes. Without that plan the sweep marks no dead entries and the indexes bloat;
the server logs `packet_sites sweep plan sets no dead-entry hints; the indexes will bloat`.
Both are written only by the server that runs background jobs.
- `beacon_packet_sites_front_live{index}` is the entries the last sweep visited, and
`beacon_packet_sites_bytes` the table's size with its indexes, hourly.
- `beacon_index_bloat_ratio` covers the four `packet_sites` indexes. Their baselines are the
sizes of a fresh build, applied once the table holds 100,000 rows, so the ratio reads against
a compact index rather than the size the server first saw.
Suggested rules are in [Example alert rules](#example-alert-rules): repaired rows in the last
10 minutes, trusted at 0 for more than 15 minutes, a watermark older than 15 minutes, a sweep older than
300 s or a sweep plan at 0, a bloat ratio of the three time-ordered indexes over 2.5 (warn) or
3, and the table over 2.5 GiB (warn; scale it for your deployment).
### Rolling back
Deploy the previous image. It ignores `packet_sites`, still has the previous release's
indexes, and the table's rows are deleted with their packets, so it does not grow stale rows.
Leave the table and its indexes in place.
To roll forward again later, deploy the new image. Before it serves any region page from the
table, the boot reconcile adds the rows written meanwhile, starting from the saved watermark;
region pages answer `503` until then. Nothing to do by hand.
Rebuild the table from scratch only if the verification keeps failing:
```sql
DELETE FROM background_state WHERE key = 'packet_sites';
TRUNCATE packet_sites;
```
Then restart the server. It backfills and verifies again as on the first install.
## Pages that wait for a read model
Three page families are served only from data the server prepares, and answer `503` with
`Retry-After: 30` and code `service_unavailable` while it is not ready:
| Pages | Ready when | Kill switch |
|---|---|---|
| `/packets` with `iatas`, `iata`, `region` or `regionId` | this process trusts `packet_sites` | `readmodels.packet_sites.enabled: false` |
| `/channels/{id}/messages` and `/messages` with `iatas`, or with `scope` and no `since` (not `/messages` with `iatas` and `since` or `scope`) | this process trusts `channel_message_iatas` | `readmodels.cmi.enabled: false` |
| `/observers/{id}/adverts` | the [payload-type backfill](#after-upgrading-observation-payload-types) is done | none |
A kill switch set to `false` keeps those pages off (`503`) until it is set back. Every other
page is unaffected. Each such `503` is counted in
`beacon_api_unavailable_total{reason="not_ready",model}`, `model` being `packet_sites`, `cmi` or
`observation_payload_types`.
**What users see.** The web app and the mobile app retry a `503` that carries `Retry-After`
three times, waiting up to 30 s each, so about 90 s in all, and then show an error. A first
fill or a repair that takes longer than that means users must retry by hand (reload the page,
or pull to refresh). On the first start of the release that adds a read model, expect those
pages to be unavailable for the whole fill: minutes for `channel_message_iatas` on a
200,000-message deployment, roughly 10 to 40 minutes for `packet_sites` on a large one (longer
with a lower [`readmodels.fill.duty`](#pacing-the-backfills)), both logged once a minute as
`... backfill progress` lines with an `eta`. Plan the upgrade for a quiet hour.
**Watching it.** `beacon_readmodel_trusted{model}` is 0 while the pages wait. Alert on it
staying 0 for more than 15 minutes, per model (the rules are in
[Example alert rules](#example-alert-rules)); it fires during a long first fill as well, which
is expected. The adverts gate has no trust gauge; alert on its `503`s instead
(`BeaconAdvertsNotReady`, two hours, longer than the backfill takes on a large deployment).
## Pacing the backfills
The one-off backfills (`packet_sites`, `channel_message_iatas` and observation payload types)
read and write a lot in a short time. Run flat out on a small server they can take the whole
disk: on an 8 vCPU, 3.8 GB RAM test server with a rotational disk, the first `packet_sites` fill
drove I/O pressure to `full avg10=78%` (`/proc/pressure/io`) and the whole machine stalled
twice, sshd and journald included, until it was rebooted.
Each fill now paces itself by duty cycle. After each slice or batch it sleeps
`max(min_pause, elapsed × (1 − duty) / duty)`, where `elapsed` is how long that slice took, so
it is busy at most `duty` of the time. A slow disk makes each slice slower, which stretches the
pauses on its own. Pacing changes only when the fills run: they write the same rows, the
verification and trust rules are unchanged, and progress is saved after every slice as before.
The fill sessions also plan their statements with `plan_cache_mode = force_custom_plan` and,
when a read-model table has never been analyzed, run `ANALYZE` on it (at the session start if it
already holds rows, otherwise after the first slice). A generic plan built on a new table with no
statistics can turn a 14 s slice into one that runs for many minutes. A failed `ANALYZE` is
logged (`read-model ANALYZE failed; the fill goes on`) and the fill continues; a table that
already has statistics is left to autovacuum.
Measured on a laptop Postgres with 3 million synthetic observations (`packet_sites`, slices of
500,000 ids):
| `duty` | Fill time | Busy in fill statements |
|---|---|---|
| `1` | 77 s | 99 % |
| `0.5` (default) | 153 s | 50 % |
| `0.25` | 316 s | 25 % |
Slices of 100,000 ids cost about 7 % more fill work (85 s at `duty: 1`) but cut the longest
burst from about 14 s to about 3 s.
Settings, under [`readmodels.fill`](configuration.md#backfill-pacing) in `config.yaml`:
- `duty` (default `0.5`, at least `0.01`): the share of time a fill may be busy. No single pause
is longer than 10 minutes.
- `min_pause` (default `100ms`): the shortest pause after a slice or batch.
- `packet_sites_slice` (default `500000`) and `cmi_batch` (default `10000`): the slice and batch
sizes. A smaller slice gives shorter bursts at a small cost in total work.
**On a slow disk** (rotational, network or shared storage), set `duty: 0.25`, and consider
`packet_sites_slice: 100000` for shorter bursts. The startup line `config: loaded` shows the
values in force. While a fill runs, check `/proc/pressure/io` on the host: `full avg10` should
stay well below 50 %. Lower `duty` further if it does not. The reconcile and the hourly audit
are not paced: they cover only the observations since the last run, or one slice an hour.
## Slow region and message statements
Two kinds of request still read a large part of the observations in one statement: a region
`/packets` page whose bounded reads ran out of budget (a sparse filter such as a narrow window
or rare payload types over busy sites), and `/messages` with `iatas` and `since` or `scope`.
They run on their own pool, `api_slow`: 2 connections by default
([`database.slow_pool_size`](configuration.md#database-pools-and-cost-guards)),
`statement_timeout=120s`, no parallel workers, not gated by the cost guards. When every
connection is busy a request waits at most 5 s and then answers `503` with `Retry-After: 5`,
without touching the other pools; `beacon_api_unavailable_total{reason="slow_pool_busy"}` counts
those, and the server logs `api: slow requests refused, every slow connection is busy` at most
once a minute. A statement that passes 120 s ends with a `500`; narrowing the request (fewer
sites, a wider window) avoids it.
Before this release the region statement ran on `api_legacy` without a time limit for a
millisecond cursor, and on `api_custom` (35 s, gated by `packets_region`) for an exact page
cursor; the all-channel message statement ran on `api_legacy` without a limit.
Measured on a copy of a large production database (29.4 million observations, 561 IATAs), with
the pool's settings:
| Request | Warm | Cold cache |
|---|---:|---:|
| The busiest region (13 IATAs), three rare payload types | 3.1-3.3 s | 22.9 s |
| The 13 busiest IATAs, a one-hour window 60 hours back | 5.5 s | 23.3 s (first run) |
| Every IATA (561), a one-minute window | 50 s | 34 s |
| Every IATA (561), a one-hour window 120 hours back | 115 s | not measured |
Requests naming every IATA are not something the web or app send; the largest region on that
database has 22 IATAs. `beacon_db_time_seconds{pool="api_slow"}` and
`beacon_pool_acquire_seconds{pool="api_slow"}` show how busy the pool is.
### Reverting to the read-model fallbacks
If the read-model pages must be served while a model is not trusted, as in earlier releases,
revert the server change that removed the fallbacks rather than rolling back the image. It is a
single commit on the server's `dev` branch ("region, scope and advert pages answer 503 until
their read model is ready, instead of falling back"); reverting it gives back exactly the
previous release's tree:
```bash
git revert --no-edit <that commit>
```
Then build, deploy and restart; no configuration change is needed, because the revert also
restores `slow_pool_size`'s default of 8 (an explicit `slow_pool_size` in `config.yaml` still
wins). What to expect:
- Untrusted region `/packets` pages, channel message pages and observer advert pages are served
by the previous statements again, with no `503` from a read model.
- All four previous statements (region, per-channel and all-channel messages, adverts) run on
`api_slow`: 8 connections, like the old `api_legacy`, ungated, with the 5 s connection wait and
a 120 s statement limit (the old pool had no limit). The total connection count grows by 6.
- If migration `046` (the drops release) has run, rebuild `idx_observations_observer_id` first,
because the restored adverts statement reads it:
```sql
CREATE INDEX CONCURRENTLY idx_observations_observer_id ON packet_observations USING btree (observer_id, id);
SELECT indisvalid AND indisready FROM pg_index WHERE indexrelid = 'idx_observations_observer_id'::regclass;
```
The second statement must print `t` before you deploy.
## Postgres settings
The stacks in this repo run stock `postgres:16-alpine` with no tuning. The settings below suit
a busy deployment. Run them as the database superuser (`POSTGRES_USER`, `beacon` in the shipped
compose files) in `psql` as above.
### Command line or `ALTER SYSTEM`
A setting passed on the Postgres command line (`postgres -c name=value`, in the `command:` of
the `db` service) wins over `ALTER SYSTEM`, and `ALTER SYSTEM` cannot change it. Check where
each setting comes from first:
```sql
SELECT name, setting, source FROM pg_settings WHERE name IN ('jit','wal_compression','max_wal_size','checkpoint_timeout','checkpoint_completion_target','bgwriter_lru_maxpages','bgwriter_lru_multiplier','bgwriter_delay','track_io_timing','session_preload_libraries','log_autovacuum_min_duration','log_lock_waits','log_temp_files','log_min_duration_statement');
```
For a row whose `source` is `command line`, change the value in the container command instead;
it takes effect at the next Postgres restart. The shipped compose files set no command, so
there everything comes from `ALTER SYSTEM` or the defaults.
### Settings that apply on reload
These take effect without a restart. Two checks first:
- `docker compose exec db pg_test_timing` must report a per-loop time under 100 ns. If it is
higher, skip `track_io_timing`; timing every read would cost more than it tells you.
- `LOAD 'auto_explain';` must succeed in `psql`. If it fails, do not set
`session_preload_libraries`: a library that cannot load there breaks every new connection,
Beacon's included.
Then:
```sql
CREATE EXTENSION IF NOT EXISTS pg_stat_statements; -- track=top, max=5000 are already the defaults
ALTER SYSTEM SET jit = off;
ALTER SYSTEM SET max_wal_size = '16GB';
ALTER SYSTEM SET checkpoint_timeout = '30min';
ALTER SYSTEM SET checkpoint_completion_target = 0.9;
ALTER SYSTEM SET bgwriter_lru_maxpages = 1000;
ALTER SYSTEM SET bgwriter_lru_multiplier = 4;
ALTER SYSTEM SET bgwriter_delay = '100ms';
ALTER SYSTEM SET log_autovacuum_min_duration = '60s';
ALTER SYSTEM SET log_lock_waits = on;
ALTER SYSTEM SET log_temp_files = '64MB';
ALTER SYSTEM SET log_min_duration_statement = '5s';
ALTER SYSTEM SET wal_compression = 'zstd'; -- only if its source is not 'command line'
LOAD 'auto_explain'; -- must succeed first: a bad name breaks every new connection
ALTER SYSTEM SET session_preload_libraries = 'auto_explain';
ALTER SYSTEM SET auto_explain.log_min_duration = '2s';
ALTER SYSTEM SET auto_explain.log_format = 'json';
ALTER SYSTEM SET auto_explain.log_analyze = off;
ALTER SYSTEM SET track_io_timing = on; -- only after pg_test_timing in the container shows < 100 ns
SELECT pg_reload_conf();
DROP INDEX CONCURRENTLY idx_known_routes_iata_last_seen;
```
Notes:
- `max_wal_size = '16GB'` lets WAL grow that far between checkpoints. Make sure the volume
under `data/postgres/` has the room, or pick a smaller value on a small host.
- `pg_stat_statements` only collects once it is in `shared_preload_libraries`, which needs a
restart. If it is not there yet, add it at the restart in the next section. Beacon's
migration 010 creates the extension itself where it can; see
[Statement statistics](#statement-statistics).
- `DROP INDEX CONCURRENTLY` cannot run inside a transaction block; run it on its own in
autocommit mode, as `psql` does by default. It takes a brief `SHARE UPDATE EXCLUSIVE` lock
and waits for older transactions, so run the pre-deploy check above first. It frees about
10 GB on a large deployment. Leave `idx_packets_payload_type` and `idx_packets_route_type`
alone; packet queries still use them.
Verify:
```sql
SELECT name, setting, source, pending_restart FROM pg_settings WHERE name IN ('jit','wal_compression','max_wal_size','checkpoint_timeout','checkpoint_completion_target','bgwriter_lru_maxpages','bgwriter_lru_multiplier','bgwriter_delay','track_io_timing','session_preload_libraries','log_autovacuum_min_duration','log_lock_waits','log_temp_files','log_min_duration_statement');
SELECT to_regclass('idx_known_routes_iata_last_seen'); -- NULL once the index is gone
```
`pending_restart` must be false on every row. Open a new `psql` session and run
`SHOW session_preload_libraries;`; it should connect and show `auto_explain`. Statements over
2 s then appear in `docker compose logs db` with their plans as JSON.
Rollback: `ALTER SYSTEM RESET <name>;` for each setting, then `SELECT pg_reload_conf();`. If
new connections fail after the `session_preload_libraries` change, reset that one first from a
session that is still open. The dropped index had no readers; if you ever want it back, build
it with `CREATE INDEX CONCURRENTLY idx_known_routes_iata_last_seen ON known_routes (iata, last_seen DESC);`.
### Settings that need a restart
Raise the freeze ages, so autovacuum starts its anti-wraparound passes later and less often:
```sql
ALTER SYSTEM SET autovacuum_freeze_max_age = 400000000;
ALTER SYSTEM SET vacuum_freeze_table_age = 300000000;
```
`autovacuum_freeze_max_age` only changes at a restart, so restart Postgres off-peak. It takes
about 10 seconds; expect a short ingest catch-up after it.
At the same restart, apply anything from the reload step whose source is `command line`, for
example `wal_compression=zstd`, and `shared_preload_libraries=pg_stat_statements` if that is
not loaded yet. Put them in the `db` service's command:
```yaml
db:
image: postgres:16-alpine
command: ["postgres", "-c", "wal_compression=zstd"]
```
If the service already has a `command:`, edit its `-c` values rather than adding a second one.
Then `docker compose up -d db` if you changed the command, or `docker compose restart db` if
you only ran the `ALTER SYSTEM` lines.
Verify:
```sql
SHOW autovacuum_freeze_max_age;
SHOW wal_compression;
SELECT max(age(datfrozenxid)) FROM pg_database;
```
Rollback: `ALTER SYSTEM RESET autovacuum_freeze_max_age; ALTER SYSTEM RESET vacuum_freeze_table_age;`,
put the old command back, and restart again.
## Rebuilding the route keyset indexes
`/routes` paging and route retention read two indexes on `known_routes`,
`idx_known_routes_keyset` and `idx_known_routes_iata_keyset`. Steady deletes and upserts leave
them bloated, so rebuild them every 7 to 14 days, and once after upgrading to the release that
drops the unused route indexes (`idx_known_routes_last_seen`, `idx_known_routes_hop_count`
and `idx_known_routes_reconfirm`).
Rebuild one index at a time, off-peak. Each rebuild writes a full new copy before it drops
the old one, so check first that the volume under `data/postgres/` has more than twice the
index's size free:
```sql
SELECT pg_size_pretty(pg_relation_size('idx_known_routes_keyset')),
pg_size_pretty(pg_relation_size('idx_known_routes_iata_keyset'));
```
```bash
df -h data/postgres
```
Then run the pre-deploy check above and, in autocommit mode:
```sql
REINDEX INDEX CONCURRENTLY idx_known_routes_keyset;
```
When it has finished, check the disk again and run:
```sql
REINDEX INDEX CONCURRENTLY idx_known_routes_iata_keyset;
```
Reads and writes carry on during the rebuild. If one is interrupted (cancelled, connection
lost, server restarted), it leaves an invalid copy named after the index with a `_ccnew`
suffix. The original index is still in place and still used. Find the leftover and drop it
before trying again:
```sql
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
DROP INDEX CONCURRENTLY IF EXISTS idx_known_routes_keyset_ccnew; -- or idx_known_routes_iata_keyset_ccnew
```
## Database pools and connections
Beacon opens a separate connection pool per workload, sized by `database.api_plan_layout`
(see [Configuration](configuration.md#database-pools-and-cost-guards)). Each pool sets its own
session settings and an `application_name`, so `pg_stat_activity` shows which one a
connection belongs to:
| Pool | `application_name` | Size | Session settings |
|---|---|---:|---|
| `api_legacy` | `beacon-api_legacy` | 8, `single` only | Server defaults. The rollback layout's only API pool. |
| `api_slow` | `beacon-api_slow` | 2 (`slow_pool_size`), not under `single` | Server defaults, with `statement_timeout=120s` and `max_parallel_workers_per_gather=0`. Only the [slow region and message statements](#slow-region-and-message-statements). |
| `api_custom` | `beacon-api_custom` | 8 (7 under `split`) | `plan_cache_mode=force_custom_plan`, `jit=off`, `max_parallel_workers_per_gather=0`, `statement_timeout=35s`, `idle_in_transaction_session_timeout=30s` |
| `api_cached` | `beacon-api_cached` | 5, `split` only | As `api_custom`, with `plan_cache_mode=force_generic_plan` |
| `ingest` | `beacon-ingest` | 6, or one per CPU above 6 | `plan_cache_mode=auto`, `jit=off`, no statement timeout: an ingest write waits out a lock rather than drop a packet |
| `maint` | `beacon-maint` | 4 | `plan_cache_mode=force_custom_plan`, `jit=off`, `max_parallel_workers_per_gather=0`. Background jobs, importers, backups and admin queries. |
The `single` layout opens only `api_legacy`, `ingest` and `maint`; the other layouts never open
`api_legacy`.
- **Headroom.** `SHOW max_connections;` must leave room for the total (20 by default on up to
6 CPUs) plus every other client: `psql` sessions, backups, monitoring. If your deploy starts
the new server before the old one stops, count both.
- **Checking.** Parallel workers carry their leader's `application_name`, so count client
backends only:
```sql
SELECT application_name, count(*) FROM pg_stat_activity
WHERE application_name LIKE 'beacon-%' AND backend_type = 'client backend' GROUP BY 1;
```
No pool should go above its size, `beacon-api_cached` should be absent under `custom_all`,
and `beacon-api_legacy` absent except under `single`.
- **`pool_max_conns`.** A `pool_max_conns` in `POSTGRES_DSN` is overridden by the pool sizes;
the server logs that at startup.
- **PgBouncer.** The session settings are sent as startup parameters. Behind PgBouncer,
`ignore_startup_parameters` must list `plan_cache_mode`, `jit`, `statement_timeout`,
`idle_in_transaction_session_timeout` and `max_parallel_workers_per_gather`, or connections
are refused. PgBouncer then drops them, so the pools run on the server defaults; connect
Beacon to Postgres directly where you can.
- **Background jobs** share the 4 `maint` connections without parallel plans, so a job may
take a little longer than before. `beacon_job_duration_seconds` shows it.
### Small hosts
Each hourly analytics rollup sets `work_mem` to 256 MB for its own transaction, and Postgres
may use that much for every sort or hash step in it. On a host with little memory, watch the
`db` container's memory while rollups run (about 95 minutes past each hour).
## Statement statistics
`GET /api/v1/admin/db/statements` sums `pg_stat_statements` by statement name (format in the
[API contract](api-contract.md#admin)). It needs the extension created and preloaded:
- **Created.** Migration `010_pg_stat_statements.sql` creates the extension when the server
offers it and the database role may. If not, it logs a notice and is still recorded, so
startup never fails over it. The shipped stacks connect as the superuser, so it is created.
Otherwise run `CREATE EXTENSION pg_stat_statements;` as a superuser.
- **Preloaded.** It only collects once `shared_preload_libraries` includes it, which needs a
Postgres restart. Add it to the `db` service's command at the next restart (see
[Settings that need a restart](#settings-that-need-a-restart)), keeping any `-c` values
already there:
```yaml
db:
command: ["postgres", "-c", "shared_preload_libraries=pg_stat_statements"]
```
Until both are true the endpoint returns `503`. Check with `SHOW shared_preload_libraries;`
and a request to the endpoint with the admin key.
Planning time (`plan_ms`) is only recorded while `pg_stat_statements.track_planning` is on. It
adds overhead, so turn it on for one day to measure, then off again; both apply on reload:
```sql
ALTER SYSTEM SET pg_stat_statements.track_planning = on;
SELECT pg_reload_conf();
-- a day later
ALTER SYSTEM RESET pg_stat_statements.track_planning;
SELECT pg_reload_conf();
```
API statements are planned on every call, so summed `plan_ms` should stay a small fraction of
summed `exec_ms`, under about 7%.
## Metrics
With `server.admin_listen` set, the server opens a second listener that serves only
`GET /metrics` in Prometheus format. It has no authentication. `/metrics` is not served on the
public port.
In the Docker stack the listener runs inside the `app` container, so bind it to all of the
container's interfaces and publish it on the host's loopback only:
```yaml
# config.yaml
server:
admin_listen: "0.0.0.0:9090"
```
```yaml
# docker-compose.yml, app service
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:9090:9090"
```
Never route it through Caddy. Scrape it from the host:
```yaml
scrape_configs:
- job_name: beacon
static_configs:
- targets: ["127.0.0.1:9090"]
```
| Metric | Type | Labels | What it is |
|---|---|---|---|
| `beacon_http_request_seconds` | histogram | `route`, `family` | HTTP request time by route pattern |
| `beacon_db_time_seconds` | histogram | `statement`, `pool` | Time per database statement |
| `beacon_pool_acquire_seconds` | histogram | `pool` | Wait for a pooled connection |
| `beacon_guard_db_seconds` | histogram | `family`, `class` | Database time per guarded request |
| `beacon_guard_would_refuse_total` | counter | `family`, `guard` | Requests a guard would refuse if its family enforced |
| `beacon_guard_refused_total` | counter | `family`, `guard` | Requests a guard refused |
| `beacon_ingest_queue_depth` | gauge | | Messages waiting in the ingest queue |
| `beacon_ingest_dropped_total` | counter | | Messages dropped because the ingest queue was full |
| `beacon_job_duration_seconds` | histogram | `job` | Background job run time (`cleanup`, `analytics_rollup`, `reconfirm`, `route_retention`, ...) |
| `beacon_job_rows_total` | counter | `job` | Rows removed by background jobs, today `reconfirm` and `route_retention` |
| `beacon_index_bytes` | gauge | `index` | Size of each index, hourly |
| `beacon_index_bloat_ratio` | gauge | `index` | Bytes per row against the last rebuild, hourly. See [Index health](#index-health). |
| `beacon_pg_wal_bytes_total` | counter | | WAL written, cluster-wide |
| `beacon_pg_checkpoints_total` | counter | `kind` | Checkpoints, `timed` or `requested` |
| `beacon_pg_xid_age` | gauge | | Age of the oldest transaction ID in the database |
| `beacon_planguard_failures` | gauge | `entry`, `mode` | 1 for each corpus entry and plan mode that failed the last [plan check](#turning-on-the-daily-check) |
| `beacon_planguard_last_run_timestamp_seconds` | gauge | | Unix time the last successful plan check finished; 0 until the first one |
| `beacon_readmodel_trusted` | gauge | `model` | 1 while this process serves pages from the read model ([`cmi`](#read-models-channel-messages-by-site) or [`packet_sites`](#read-models-packets-by-site)), 0 while those pages answer `503` |
| `beacon_readmodel_repaired_rows_total` | counter | `model` | Rows the read-model reconcile repaired; above zero, a writer bypassed the model |
| `beacon_readmodel_watermark_age_seconds` | gauge | `model` | Seconds since the last finished reconcile of the read model (on a read-only server, since the writer last saved its state) |
| `beacon_packet_sites_sweep_age_seconds` | gauge | | Seconds since the last successful `packet_sites` front sweep; absent before the first |
| `beacon_packet_sites_sweep_plan_ok` | gauge | | 1 while the front sweep plans as index scans on its three indexes, 0 otherwise; checked at the first sweep, then hourly |
| `beacon_packet_sites_front_live` | gauge | `index` | Index entries written in the last two minutes that the last front sweep visited |
| `beacon_packet_sites_bytes` | gauge | | Total size of `packet_sites`, heap and indexes, hourly |
| `beacon_db_sequence_used_ratio` | gauge | `sequence`, `cycle` | Share of a sequence's usable values already handed out, 0 to 1, each minute. The usable range ends at the sequence's own limit or its column's type limit (`integer` 2,147,483,647), whichever is lower. `cycle="true"` marks a `CYCLE` sequence, whose ratio is its position, not used-up headroom. See [Sequence headroom](#sequence-headroom). |
| `beacon_api_unavailable_total` | counter | `reason`, `model` | `503`s a page answered: `not_ready` while its read model or backfill is not ready, `slow_pool_busy` when `api_slow` had no free connection, `timeout` when its statement ran past its pool's statement limit or an enforced guard's deadline. `model` is `packet_sites`, `cmi` or `observation_payload_types`. |
`guard` is `deadline`, `cap`, `admission` or `budget`. The Postgres counters are read once a
minute; the index gauges first appear an hour after startup. The two `beacon_planguard_*`
gauges exist only with `planguard.daily_check: true`. Go runtime and process metrics
are included too.
### Example alert rules
```yaml
groups:
- name: beacon
rules:
- alert: BeaconGuardWouldRefuse # any would-refuse delays enforcing that family
expr: sum by (family, guard) (increase(beacon_guard_would_refuse_total[1h])) > 0
- alert: BeaconDeadlineHits
expr: sum(increase(beacon_guard_refused_total{guard="deadline"}[1h])) > 20
- alert: BeaconListSlow # list-class database time p99 over 1 s
expr: histogram_quantile(0.99, sum by (le) (rate(beacon_guard_db_seconds_bucket{class="list"}[15m]))) > 1
for: 15m
- alert: BeaconPoolAcquireSlow
expr: histogram_quantile(0.99, sum by (le, pool) (rate(beacon_pool_acquire_seconds_bucket[5m]))) > 0.5
for: 10m
- alert: BeaconCleanupLong # a cleanup run over 10 min in the last 2 h
expr: increase(beacon_job_duration_seconds_count{job="cleanup"}[2h]) - increase(beacon_job_duration_seconds_bucket{job="cleanup",le="600"}[2h]) > 0
- alert: BeaconKeysetBloat
expr: beacon_index_bloat_ratio{index=~"idx_known_routes_(iata_)?keyset"} > 4
- alert: BeaconCheckpointsRequested
expr: increase(beacon_pg_checkpoints_total{kind="requested"}[1d]) > increase(beacon_pg_checkpoints_total{kind="timed"}[1d])
- alert: BeaconWALAboveBaseline # set the baseline from your own steady state
expr: rate(beacon_pg_wal_bytes_total[1h]) > <baseline bytes/s>
- alert: BeaconXIDAge
expr: beacon_pg_xid_age > 300e6
# The two plan check rules only matter with planguard.daily_check: true.
- alert: BeaconPlanguardFailures # the last plan check found a regression
expr: sum(beacon_planguard_failures) > 0
- alert: BeaconPlanguardStale # no successful plan check for 26 h
expr: time() - beacon_planguard_last_run_timestamp_seconds > 26*3600 and on() time() - process_start_time_seconds > 26*3600
# The read-model rules don't apply with readmodels.cmi.enabled: false, where trusted stays 0.
- alert: BeaconReadModelRepairing # reconciles repaired rows in 3 consecutive 5-minute windows
expr: min_over_time((increase(beacon_readmodel_repaired_rows_total{model="cmi"}[5m]) > bool 0)[15m:5m]) == 1
- alert: BeaconReadModelUntrusted # region and scope message pages answering 503
expr: beacon_readmodel_trusted{model="cmi"} == 0
for: 15m
- alert: BeaconReadModelStale # no finished reconcile (or writer save) for 15 min
expr: beacon_readmodel_watermark_age_seconds{model="cmi"} > 900
# The packet_sites trust rules don't apply with readmodels.packet_sites.enabled: false.
# Install the two sweep rules only for the server that runs background jobs, never for a
# read-only server: it never sweeps, so its sweep_plan_ok stays 0 and the plan rule would fire.
- alert: BeaconPacketSitesRepairing # a reconcile or audit repaired rows: a writer bypassed the model
expr: increase(beacon_readmodel_repaired_rows_total{model="packet_sites"}[10m]) > 0
- alert: BeaconPacketSitesUntrusted # region packet pages answering 503
expr: beacon_readmodel_trusted{model="packet_sites"} == 0
for: 15m
- alert: BeaconPacketSitesStale # no finished reconcile (or writer save) for 15 min
expr: beacon_readmodel_watermark_age_seconds{model="packet_sites"} > 900
- alert: BeaconPacketSitesSweepStale
expr: beacon_packet_sites_sweep_age_seconds > 300
- alert: BeaconPacketSitesSweepPlan # the sweep sets no dead-entry hints; the indexes will bloat
expr: beacon_packet_sites_sweep_plan_ok == 0
for: 5m
- alert: BeaconPacketSitesBloatWarn
expr: beacon_index_bloat_ratio{index=~"idx_packet_sites_site(_type|_scope)?"} > 2.5
- alert: BeaconPacketSitesBloat
expr: beacon_index_bloat_ratio{index=~"idx_packet_sites_site(_type|_scope)?"} > 3
- alert: BeaconPacketSitesSize # warn; scale 2.5 GiB for your deployment
expr: beacon_packet_sites_bytes > 2.5 * 1024^3
- alert: BeaconAdvertsNotReady # observer advert pages answering 503 for over 2 h
expr: sum(increase(beacon_api_unavailable_total{reason="not_ready",model="observation_payload_types"}[15m])) > 0
for: 2h
- alert: BeaconSlowPoolBusy # region or message requests refused for want of a slow connection
expr: sum(increase(beacon_api_unavailable_total{reason="slow_pool_busy"}[15m])) > 10
# The sequence rules leave out CYCLE sequences, which wrap instead of running out.
- alert: BeaconSequenceHalfUsed
expr: beacon_db_sequence_used_ratio{cycle="false"} > 0.5
for: 1h
labels: {severity: warning}
annotations: {summary: "Sequence {{ $labels.sequence }} has used over half of its values"}
- alert: BeaconSequenceRunsOutIn90Days
expr: predict_linear(beacon_db_sequence_used_ratio{cycle="false"}[7d], 90 * 86400) >= 1
for: 6h
labels: {severity: critical}
annotations: {summary: "Sequence {{ $labels.sequence }} runs out within 90 days at its current rate"}
```
The two `Untrusted` rules fire while a read model's pages answer `503` for more than 15
minutes, including during a long first fill after an upgrade; see
[Pages that wait for a read model](#pages-that-wait-for-a-read-model).
Replace `<baseline bytes/s>` with your normal WAL rate before loading the rules. The route
recheck has no metric yet; at `debug` level it logs a `route reconfirm` line per slice, and
`wrapped=true` marks the end of a full cycle.
## Enforcing a guard family
Every guard family only observes until you enforce it (see
[Configuration](configuration.md#database-pools-and-cost-guards)). Observe for 7 days with
metrics on, then enforce a family only when all three hold for it over those 7 days. With
`routes` as the example (Prometheus needs at least 7 days of retention):
1. No would-refuse from any guard. Every series must be 0:
```promql
sum by (guard) (increase(beacon_guard_would_refuse_total{family="routes"}[7d]))
```
2. The p99 database time of each class is at most a quarter of its limit: entity 1.25 s, list
2.5 s, aggregation 7.5 s.
```promql
histogram_quantile(0.99, sum by (le, class) (rate(beacon_guard_db_seconds_bucket{family="routes"}[7d])))
```
3. No request used more than half its limit. Histograms have no exact maximum, so check that
no request landed above the bucket at or under half the limit: `le="2.5"` for entity,
`le="5"` for list, `le="10"` for aggregation (stricter than 15 s, which has no bucket). Each
must be 0:
```promql
sum(increase(beacon_guard_db_seconds_count{family="routes",class="list"}[7d]))
- sum(increase(beacon_guard_db_seconds_bucket{family="routes",class="list",le="5"}[7d]))
```
Then enforce it at runtime, listing every family that should enforce (the list replaces the
current one):
```bash
curl -s -X PUT https://<your-domain>/api/v1/admin/config \
-H "Authorization: Bearer $BEACON_API_KEY" -H 'Content-Type: application/json' \
-d '{"guards":{"enforce":["other","routes"]}}'
```
The response's `config.guards.enforce` shows the new list. Add the same list to
`guards.enforce` in `config.yaml`, or the next restart goes back to the old one. To stop
enforcing, `PUT` the list without that family (`[]` for none). `beacon_guard_refused_total`
then counts real refusals.
`other` and `routes` are the families to start with. The rest are not ready in this release:
`channel_messages` statements are not guarded yet, and the packet and stats families still
run statements that later releases make cheaper. Their release notes will say when each is
ready, roughly `channel_messages` first, then `packets_global`, then `packets_region` and
`packets_window`, then `stats` and `packet_detail`. `packets_window` may stay observed for
good.
## Index health
Once an hour the server records each index's size (`beacon_index_bytes`) and its bytes per
row against a baseline (`beacon_index_bloat_ratio`). The baseline is taken the first time the
server sees the index and again whenever it is rebuilt, so the ratio is growth since the last
rebuild, not absolute bloat: 1 means as compact as then, 4 means four times the bytes per
row. An index that has never been analyzed has no ratio.
The four `packet_sites` indexes are never built fresh on a full table, so their first-seen size
would make a poor baseline. They start from the bytes per row of a fresh build instead, once
the table holds 100,000 rows; below that they have no ratio. A rebuild of one of them
re-baselines it from its measured size, as for any index.
The first run, and then one run a week, also logs `indexes with no scans since the last
statistics reset` with each unused non-unique index and its size. An index that stays on that
list for weeks is a candidate to drop; check that no rare job reads it first.
When `BeaconKeysetBloat` fires, rebuild that index as in
[Rebuilding the route keyset indexes](#rebuilding-the-route-keyset-indexes). The rebuild resets
its baseline, and the ratio drops back to about 1 on the next hourly run.
## Lookup-table indexes and the route stamp column
Migrations `047` to `052` drop six indexes that no statement's plan reads; every lookup they
served goes through a primary key or a table of a few hundred rows:
| Index | Table | Why it goes | Rebuild (seconds) |
|---|---|---|---|
| `idx_channel_iatas_iata` | `channel_iatas` | nothing looks up channel sites by IATA | `CREATE INDEX CONCURRENTLY idx_channel_iatas_iata ON channel_iatas USING btree (iata);` |
| `idx_node_neighbors_node` | `node_neighbors` | the primary key `(node_id, neighbor_id, iata)` serves every lookup by node | `CREATE INDEX CONCURRENTLY idx_node_neighbors_node ON node_neighbors USING btree (node_id, iata);` |
| `idx_transport_scopes_fingerprint` | `transport_scopes` | scopes are loaded whole | `CREATE INDEX CONCURRENTLY idx_transport_scopes_fingerprint ON transport_scopes USING btree (key_fingerprint);` |
| `idx_meshmapper_channel_members_fingerprint` | `meshmapper_channel_members` | channel pages read members by IATA through the primary key | `CREATE INDEX CONCURRENTLY idx_meshmapper_channel_members_fingerprint ON meshmapper_channel_members USING btree (key_fingerprint);` |
| `idx_region_iatas_iata` | `region_iatas` | regions are read by id; the IATA lookup reads a tiny table | `CREATE INDEX CONCURRENTLY idx_region_iatas_iata ON region_iatas USING btree (iata);` |
| `idx_trace_tags_first_heard` | `trace_tags` | trace pages walk the keyset index | `CREATE INDEX CONCURRENTLY idx_trace_tags_first_heard ON trace_tags USING btree (first_heard_at);` |
`idx_analytics_scope_sets_scope` stays: `/scopes/{name}` reads it twice, and without it a rare
scope's lookup becomes two full scans of the scope rollup table.
No older image needs the six indexes for its plans; rebuilding them after a rollback is
optional.
Migration `053` drops `known_routes.last_reconfirmed_at`. No statement of this release reads
it: route reconfirm stopped stamping it and its index went in earlier releases. The old values
are gone for good (no client ever saw them). Every older image needs the column back before it
starts, because its route evidence statement lists it and 2.0.7's reconfirm writes it:
```sql
ALTER TABLE known_routes ADD COLUMN last_reconfirmed_at timestamp with time zone DEFAULT now() NOT NULL;
```
This is a catalog change, not a rewrite (8 ms on a 10.5-million-row copy), and takes an
`ACCESS EXCLUSIVE` lock on `known_routes` for that long. Every route reads the time of the
`ALTER`; 2.0.7 then walks routes in arbitrary order on its first reconfirm cycle, which changes
nothing it deletes. To roll forward again, the new image's migration `053` is already recorded,
so drop the column by hand with `ALTER TABLE known_routes DROP COLUMN last_reconfirmed_at;`, or
delete the `053_drop_known_routes_last_reconfirmed_at.sql` row from `schema_migrations` so the
migration runs again.
## Sequence headroom
`beacon_db_sequence_used_ratio{sequence}` is, for every sequence in Beacon's schema, the share
of its usable values already handed out: 0 when unused, 1 when exhausted. The usable range ends
at the sequence's own maximum or at its column's type limit, whichever is lower, so an
`integer` column on a `bigint` sequence counts against 2,147,483,647. A descending sequence
counts from its top. A `CYCLE` sequence wraps to its start instead of running out, so it is
reported by position with `cycle="true"`, and the example rules leave it out; Beacon creates
none.
On a large deployment `channels_id_seq` read about 0.02 and every `bigint` sequence about 0.
`channels_id_seq` used to burn about 2,000 values a minute through upsert conflicts (fixed by
the channel write-through); the `BeaconSequenceRunsOutIn90Days` rule in
[Example alert rules](#example-alert-rules) would have caught that. An `integer` sequence
nearing 1 needs its column changed to `bigint`, a table rewrite to plan off-peak.
After deploying, check that `channels_id_seq` stays flat over an hour:
`delta(beacon_db_sequence_used_ratio{sequence="channels_id_seq"}[1h])` near 0.
## Plan checks (planguard)
planguard checks that PostgreSQL still plans each of Beacon's statements the way it was designed.
Its corpus, in the server repo under `db/planguard/`, lists every API and job statement with
parameters and what its plan must look like: the driving index and its index conditions, the
scans it must never use, and buffer, row and time budgets. It runs at three tiers against three
databases:
| Tier | Database | How it plans | Checks | Who runs it |
|---|---|---|---|---|
| `fixture` | A synthetic database built through the migrations | Plain `EXPLAIN` | Plan shapes, against the committed `baseline/fixture` | CI, on every server pull request |
| `local` | A copy of a large production database, or a restored dump | `EXPLAIN (ANALYZE, BUFFERS)`, read-only | Plan shapes and budgets, against your own `baseline/local` | Maintainers, before a release |
| `prod` | A live production database | Plain `EXPLAIN`, read-only, 5 s per plan | Plan shapes only, no baseline | The server's [daily check](#turning-on-the-daily-check), or by hand through psql |
Every tier runs each statement inside a `READ ONLY` transaction with a 1 s lock timeout and rolls
it back. Only the `local` tier executes statements, and then only reads; writes are only ever
planned.
Each run gets a verdict:
| Verdict | Meaning | Blocks a release |
|---|---|---|
| `ok` | The plan matches its expectations. | No |
| `FALSE_POSITIVE` | A statement that should pass broke a rule: a real regression, or an expectation that no longer holds on this database. | Yes |
| `MISSED` | A planted regression (an entry that must fail) passed, so the check itself is not catching what it should. | Yes |
| `ERROR` | The statement could not be planned. | Yes |
| `DETECTED` | A planted regression failed, as it should. | No |
| `RETIRED_INDEX` | The only failures name an index that a pending change drops. Expected until that change ships. | No |
| `KNOWN_ISSUE` | A failure the corpus already records as known. | No |
| `SKIP` | The run was not made, for example a statement whose feature is not built yet or whose parameters found no value. | No |
The command line tool is `cmd/planguard` in the server repo. Run it from a checkout of the commit
you are about to release. It exits 0 when no run fails and 1 when one does, and writes its
results to `planguard-out/`.
### Fixture tier
CI builds the fixture and runs this tier on every pull request. To run it yourself you need an
empty database with C collation on PostgreSQL 16 (CI pins 16.15):
```sql
CREATE DATABASE planguard TEMPLATE template0 LC_COLLATE 'C' LC_CTYPE 'C' ENCODING 'UTF8';
```
```bash
go run ./cmd/planguard run --tier fixture --dsn 'postgres://...:5432/planguard'
```
The first run builds the fixture through the migrations, which takes a minute or two; later runs
reuse it while its inputs are unchanged. The builder refuses a database that already holds data
or is not C-collated.
### Local tier
The local tier is the release gate. Run it against a copy of a large production database, or a
restored dump, never against the live one: it executes every read statement. The copy needs its
own baseline (written to `db/planguard/baseline/local/` in your checkout), so write one on the
first run, review it, then check against it:
```bash
go run ./cmd/planguard run --tier local --dsn "$COPY_DSN" --baseline write
go run ./cmd/planguard run --tier local --dsn "$COPY_DSN" # --baseline check is the default
```
A plan that changes against the baseline fails the check run. When the change is intended, write
the baseline again and keep it with the copy. `--only` and `--skip` take a regular expression of
entry ids to narrow a run.
### Prod tier through psql
The server's daily check runs the prod tier itself. To get the same evidence from a database the
server does not check, for example before turning the check on, emit read-only psql scripts and
run them yourself. Nothing connects from the tool; you run each script and hand its output back:
```bash
go run ./cmd/planguard run --tier prod --emit resolvers > resolvers.sql
docker compose exec -T db psql -X -U beacon -d beacon < resolvers.sql > resolvers.out
go run ./cmd/planguard run --tier prod --emit plans --params resolvers.out > plans.sql
docker compose exec -T db psql -X -U beacon -d beacon < plans.sql > plans.out
go run ./cmd/planguard run --tier prod --params resolvers.out --plans plans.out
```
The first script looks up parameter values (20 s statement timeout); the second runs plain
`EXPLAIN`s with them. Both set `default_transaction_read_only` and a 1 s lock timeout. Each
script carries a random marker, and the last step rejects output that is truncated or does not
match the script.
### Before a release
Before tagging a server release, maintainers confirm:
1. CI's fixture tier is clean.
2. The local tier, against your copy and its baseline, is clean.
3. On a deployment with the daily check on, `GET /api/v1/admin/db/planguard?fresh=true` returns
`"clean": true`, before and after the deploy.
`RETIRED_INDEX` runs are expected while an index drop is pending; each names the index that will
go.
### Turning on the daily check
The daily check is for large deployments only. Its expectations assume production-sized
tables, and on a small database the planner rightly picks other plans, which the check reports
as failures. Before turning it on, run the prod tier through psql once and look at the result.
Then set:
```yaml
planguard:
daily_check: true
```
and restart the server. It cannot be combined with `server.read_only`. The first check runs
about an hour after startup and then once a day; a failed run is retried every hour. Read the
last report, or run one now (at most one new run per 5 minutes), with the admin key:
```bash
curl -s https://<your-domain>/api/v1/admin/db/planguard \
-H "Authorization: Bearer $BEACON_API_KEY"
curl -s 'https://<your-domain>/api/v1/admin/db/planguard?fresh=true' \
-H "Authorization: Bearer $BEACON_API_KEY"
```
`clean` must be true. `failures` lists each failing run with the rules it broke and the indexes
it used. The response is described in the [API contract](api-contract.md#admin).
With [metrics](#metrics) on, add the two plan check alerts from the
[example rules](#example-alert-rules):
- `BeaconPlanguardFailures` fires when the last check had a failing run.
`beacon_planguard_failures` names the entry and mode.
- `BeaconPlanguardStale` fires when no check has succeeded for 26 hours. The last-run gauge is
0 from startup until the first success, so a check that never succeeds fires it too. The
`process_start_time_seconds` condition keeps a recent restart from firing it before the first
run has had time to happen.
To turn the check off, set `daily_check: false` and restart. Nothing is stored in the database.
### Comparing two builds
`server.read_only: true` (see [Configuration](configuration.md#read-only-mode)) lets two server
builds serve the same database copy side by side, so a change to a query can be checked against
real data before it ships. `planguard storediff` then walks the same API pages on both and
compares the whole JSON of every page.
1. Build the base and the candidate server binaries.
2. Give each a `config.yaml` with `server.read_only: true` and `ratelimit.enabled: false`, and
leave `REDIS_ADDR` unset so neither serves cached responses. Point both `POSTGRES_DSN`s at
the same copy, already migrated to the schema both builds expect. Indexes or other objects
only the candidate needs can live in a scratch schema on that copy, put first on the
candidate's search path with `?search_path=<scratch>,public` in its DSN.
3. Start them on two ports, for example 8481 and 8482. Each logs a read-only warning at startup
and no migration lines.
4. Compare them:
```bash
go run ./cmd/planguard storediff --old http://127.0.0.1:8481 --new http://127.0.0.1:8482 \
--cases db/planguard/storediff/smoke.yaml --walk 1,2,3,web --out storediff-out
```
`--walk` lists the page sizes to walk; `web` is each case's web page size. Small page sizes catch
pagination that skips or repeats rows. The tool exits non-zero when any page differs, and with
`--out` writes `storediff_report.md` there. Never use read-only mode on your live instance: it
ingests nothing.
## Rolling back the pool layout
If the new pools or plans misbehave, set this and restart the server:
```yaml
database:
api_plan_layout: single
```
It puts every API statement, the slow ones included, on one pool (`api_legacy`, 8 connections,
the server's own settings, no statement limit) and makes every guard family observe, whatever
`guards.enforce` says. The alternative is the previous server
image. It ignores migration 010; the `pg_stat_statements` extension stays installed and
harmless.
## CPU profiling
Bounded CPU captures on a running instance are described in [Profiling](profiling.md).