Monitoring
This page is for whoever runs a Postgres database with pg_improv in it,
and for anyone running improv-server. It covers what to watch, what the
signals mean, and when to page someone. It describes pg_improv 0.11.0,
the version on main. Operations runbook has what to
do when an alert fires.
Ready-made files:
docs/ops/postgres_exporter-queries.yaml: custom queries for postgres_exporter, exposing every model's status, the slot and the worker as metrics.docs/ops/grafana-improv.json: a Grafana dashboard over those metrics.
Both were checked against a PG 18.6 cluster running pg_improv 0.11.0
built from main, with postgres_exporter 0.17.1.
What to watch
flowchart LR W[writers] -->|commit| WAL[(WAL)] WAL -->|decoded each poll| S[slot improv_db] S --> K["pg_improv sync worker<br/>(one per server)"] K -->|result rows| R[(improv_‹model›.*)] K -->|counters, last_error| ST[improv.sync_state] ST --> V["improv.status(model)"] S --> RS[pg_replication_slots] K --> PA[pg_stat_activity] K --> LOG[server log]
Only logical-mode models have a worker, a slot and lag. trigger and
commit models fail inside the writer's transaction, so the writer sees
the error. A manual model changes only when someone calls
improv.recalc().
improv.status(model)
One row per model, SECURITY INVOKER, readable by anyone with USAGE on
schema improv and SELECT on its tables (row-level security hides only
a model's input cells from roles that may not act as its owner).
SELECT m.name, s.*
FROM improv.model m, improv.status(m.name) s;
| Column | Type | Meaning | Healthy |
|---|---|---|---|
sync_mode | text | logical, trigger, commit or manual. | what you set |
slot | text | improv_db in logical mode (the slot every logical model of the database shares); NULL otherwise. | improv_db |
lag_bytes | bigint | pg_current_wal_lsn() − confirmed_flush_lsn of the slot: WAL the worker has not confirmed yet. It is the slot's lag, so every logical model of the database shows the same number. NULL when the slot does not exist (not yet created, or dropped). | small and not growing; it rises to about 16 MB while the bound tables are idle and then drops, because the worker advances an idle slot once it lags 16 MB |
confirmed_lsn | text | The slot's confirmed_flush_lsn. | moving |
last_applied_at | timestamptz | When the worker last applied a batch to this model, or rebuilt it. It does not move while the model's tables are idle. NULL before the first catch-up, and after a new slot resets progress. | recent if the tables are being written |
rows_applied | bigint | Result rows the worker has written for this model (inserted, updated or deleted), in total. | rising with writes |
last_error | text | Why the worker's last attempt at this model failed; NULL after a success. While set, the model is retried with backoff (0.5 s, doubling, up to 60 s), and every retry is a full rebuild. | NULL |
incremental_applies | bigint | Batches the worker fed to the live engine (the cheap path). | rising with writes |
rebuilds | bigint | Full reloads and recomputes. The first catch-up counts one; so does each worker restart, structural change (define, a new binding), and batch that needs one (Concepts lists the triggers). | flat between deploys and restarts |
fold_bytes | bigint | Estimated heap of the model's fold state in the worker: what its aggregated bindings (agg => …) keep per coordinate. A count_distinct, min or max binding keeps its values too, so it grows with their number. 0 without aggregated bindings. Measured after each rebuild, and at most once a second while the model applies batches; NULL while the worker holds no live state for the model (before its first change, or after it failed). | stable for a stable model |
engine_bytes | bigint | Estimated heap of the model's live engine and input cells in the worker, as of the same moment. fold_bytes + engine_bytes is what improv.max_model_memory caps. | stable for a stable model; below the cap |
lag_bytes is measured against the shared slot, so it does not tell you
which model is slow. Compare last_applied_at and the counters across
models for that.
improv.status does not report the slot's wal_status. A lost slot (see
below) leaves last_error NULL on 0.11.0 and earlier, so watch
pg_replication_slots as well. D11 in the road to 1.0 makes the
worker report and repair it.
The memory columns are estimates of the worker's heap. They come from
the sizes of the structures the worker holds, within about 10 % of the
live heap measured on the shapes in the tests (maps, joins, SUM,
count, sum, count_distinct, min). They do not count the shared
buffers the worker reads through. To find the models that use the most
memory:
SELECT m.name, pg_size_pretty(s.fold_bytes + s.engine_bytes) AS live
FROM improv.model m, improv.status(m.name) s
WHERE s.fold_bytes IS NOT NULL
ORDER BY s.fold_bytes + s.engine_bytes DESC;
Capping a model's memory
improv.max_model_memory (MB, default 0 = no cap, reloadable) caps each
model's live state. The worker checks it whenever it measures the model
(after each rebuild, and at most once a second as it applies), and checks
the loaded inputs before it builds an engine. A model over
the cap is refused: the worker drops its live state, logs pg_improv sync: model <m>: the live state needs about N MB (fold state F MB, engine E MB), over improv.max_model_memory = C MB, and sets that text as
the model's last_error. It retries with backoff (each retry is a
rebuild), and other models are not held back. The model's result tables
keep their last values. Set the cap below what the server can spare for
the worker divided by the number of large models, so one model that grows
fails alone rather than getting the backend OOM-killed and taking every
model down with it.
pg_replication_slots
SELECT slot_name, active, active_pid, wal_status, safe_wal_size,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS unconfirmed
FROM pg_replication_slots
WHERE slot_name = 'improv_db';
retained(pg_current_wal_lsn() − restart_lsn) is what the slot keeps on disk inpg_wal. This is the number that fills disks.unconfirmedislag_bytesfromimprov.status.activeis true only while the worker is decoding (during a poll), so it flickers;active_pidis the worker's pid then.wal_statusisreservedorextendedwhile the slot is healthy.unreservedmeans it is pastmax_wal_sizeand about to exceedmax_slot_wal_keep_size.lostmeans Postgres removed WAL the slot needed: the slot is dead, results no longer follow writes, and on 0.11.0 and earlier the worker does not recover by itself (runbook).safe_wal_sizeis how much more WAL may be written before the slot is lost (NULL whenmax_slot_wal_keep_sizeis unlimited, which is the Postgres default; set it).
With no logical-mode model left, the worker drops the slot. A database
whose models are all trigger, commit or manual has no improv_db
row.
The worker in pg_stat_activity
SELECT pid, datname, backend_start, state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE backend_type = 'pg_improv sync';
- There is one worker per server, serving the database named by
improv.database(defaultpostgres). It exists only whenpg_improvis inshared_preload_libraries. - No row means the worker is not running. Without the preload there is never one; with it, the postmaster restarts a dead worker 5 s after it exits.
backend_startkeeps resetting when the worker is crash-looping.- Between polls it waits on the latch:
wait_event_type = 'Extension',wait_event = 'Extension'. datnameis the database it serves. Logical-mode models in any other database are never applied.
The worker's log lines
The worker logs at LOG level into the server log. Every line starts with
pg_improv sync. Here is each line in crates/pg_improv/src/worker.rs:
| Line | Meaning | Action |
|---|---|---|
pg_improv sync worker started on database "<db>" | The worker connected. Once at server start, and after each restart. | none; frequent restarts mean a crash loop |
FATAL: terminating background worker "pg_improv sync" due to administrator command | It received SIGTERM (shutdown, or pg_terminate_backend) and stopped at its next interrupt check, mid-poll if need be. It exits with status 1, so the postmaster starts it again 5 s later and it resumes from the slot position. | none |
pg_improv sync worker stopping | The postmaster died. | see why the server went down |
pg_improv sync: listing models: <error> | It could not read the catalog this poll. | check the error; it retries next poll |
pg_improv sync: dropping improv_db: <error> | No logical model is left, and dropping the slot failed. | drop it by hand if it stays |
pg_improv sync: creating the slot improv_db: <error> | Slot creation failed, usually max_replication_slots reached or wal_level not logical. No logical model is updated until it succeeds. | fix the setting and restart |
pg_improv sync: creating the publication improv_db: <error> | The publication was missing (dropped by hand) and recreating it failed. | run SELECT improv.sync_publication() and read its error |
pg_improv sync: resetting progress: <error> | A new slot was created, and resetting the models' recorded positions failed. | check the error |
pg_improv sync: reading improv_db: <error> | Decoding the slot failed, so no model was updated this poll. Repeated every poll with can no longer access replication slot "improv_db" and invalidated due to "wal_removed" in the detail, it means the slot is lost. | runbook |
pg_improv sync: model <name>: <error> | That model's apply failed. The same text is in its last_error, and it is retried with backoff. The other models are not held back. | runbook |
pg_improv sync: model <name>: rebuilding (model changed) | Its structure changed (define, bind_*), so it is rebuilt from its tables. | none; expected after a deploy |
pg_improv sync: model <name>: rebuilding (<reason>) | The batch could not be applied incrementally. The reasons are listed below. | if it repeats, see rebuild loop |
pg_improv sync: advancing improv_db: <error> | The slot could not be moved forward. The models' results are already written, but WAL is retained until the next successful advance. | check the error |
pg_improv sync: dropping queued slots: <error> | An old per-model slot (improv_<model>, from 0.10.0) left by the upgrade could not be dropped. | drop it by hand |
Rebuild reasons (in crates/pg_model/src/live.rs and worker.rs):
| Reason | Cause |
|---|---|
model changed | model_version moved while the batch waited for the model's lock |
no live state | first batch after a restart or a failure |
new item in a category a formula enumerates / new item | a new dimension value in a category whose item list a formula reads (POSITION, PREV, RANK, FILL, …) |
two rows on one coordinate | a measure bound without agg got a second row for one coordinate; bind it with agg |
min or max of text | a text min/max binding whose value column has no collation changed (rare: a text column always has one) |
nondeterministic collation | a text min, max or count_distinct binding over a column with a nondeterministic collation (deterministic = false) changed: only Postgres can tell which texts are equal |
truncate | TRUNCATE of a bound table |
old row not replicated, unchanged TOAST value, bound column missing | an UPDATE or DELETE whose old row the decoder did not carry; the table needs REPLICA IDENTITY FULL (the worker sets it on tables without a primary key) |
value the loader would not read, a fold went out of step, fold not seeded, unknown binding, unknown measure, undescribed relation, not an aggregated binding, no row | internal consistency checks; the rebuild makes the result exact again. If one repeats, report it with the model's definition |
Log volume on 0.11.0 and earlier. Every poll decodes the slot, and
Postgres itself logs two LOG lines per decoding call, each with a DETAIL
and a CONTEXT line: starting logical decoding for slot "improv_db" and
logical decoding found consistent point at …. It does this even when
nothing has changed. Measured on PG 18.6 (both PG 17 and 18 log these at
LOG): about 3.6 KB/s of server log at the default improv.poll_ms = 200
(about 310 MB a day), and 13.5 KB/s at poll_ms = 50. Rotate the server
log (log_rotation_size, log_rotation_age, or logrotate), and do not
lower poll_ms below what you need. log_min_messages cannot hide these
lines without hiding the worker's own LOG lines as well. D8 in the
road to 1.0 skips the decode when there is no new WAL and quiets
it, fixing this in 0.11.
Changing improv.poll_ms / improv.max_changes. Both take effect on
pg_reload_conf() in 0.11 (D9). On 0.10.0 and earlier, and on main
before D9 lands, the running worker keeps its old values until it
restarts:
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE backend_type = 'pg_improv sync'
(it is back 5 s later, and loses nothing).
Alerts
Thresholds are starting points for a database like the capacity planning example (one poll is 200 ms). Tune them to your write rate.
| Alert | Condition | For | Severity | Why |
|---|---|---|---|---|
| Worker down | no pg_stat_activity row with backend_type = 'pg_improv sync', with the preload configured | 1 min | page | no logical model is updated; WAL accumulates |
| Worker crash loop | worker backend_start younger than 60 s, three times in 10 min | 10 min | page | something kills it every poll; read the server log |
| Slot about to be lost | wal_status = 'unreserved' for improv_db | 0 | page | the next checkpoint may invalidate it |
| Slot lost | wal_status = 'lost' for improv_db | 0 | page | results have stopped; status stays clean. This is the only signal on 0.11.0 and earlier |
| Retained WAL high | retained bytes > 50 % of max_slot_wal_keep_size (or of free space on the pg_wal volume if unset) | 10 min | warn | the worker is behind or stopped |
| Lag growing | lag_bytes > 64 MB and rising | 15 min | warn | an idle slot stays at or below 16 MB; more than that, rising, means the worker is not keeping up |
| Model failing | last_error IS NOT NULL | 5 min | warn (page if it is a business-critical model) | the model's results are stale and every retry is a full rebuild |
| Rebuilds rising | rebuilds increases by more than 3 in 15 min outside a deploy | 15 min | warn | a rebuild loop: each costs a full recompute |
| Stale model | last_applied_at older than 10 min while its bound tables were written (n_tup_ins + n_tup_upd + n_tup_del in pg_stat_user_tables moved) | 10 min | warn | the worker is not reaching this model |
| Commit/trigger failures | the server log shows improv: model … could not recompute | any | warn | writers' transactions are being rolled back |
As PromQL over the queries file (metric names are
<query>_<column>):
groups:
- name: pg_improv
rules:
- alert: ImprovWorkerDown
expr: pg_improv_worker_running == 0
for: 1m
labels: {severity: page}
- alert: ImprovSlotLost # wal_status unreserved or lost
expr: pg_improv_slot_at_risk == 1
labels: {severity: page}
- alert: ImprovRetainedWalHigh
expr: pg_improv_slot_retained_bytes > 2 * 1024^3 # half of a 4 GB max_slot_wal_keep_size
for: 10m
labels: {severity: warn}
- alert: ImprovLagGrowing
expr: pg_improv_status_lag_bytes{sync_mode="logical"} > 64 * 1024^2
and deriv(pg_improv_status_lag_bytes{sync_mode="logical"}[15m]) > 0
for: 15m
labels: {severity: warn}
- alert: ImprovModelFailing
expr: pg_improv_status_last_error == 1
for: 5m
labels: {severity: warn}
- alert: ImprovRebuildsRising
expr: increase(pg_improv_status_rebuilds[15m]) > 3
labels: {severity: warn}
Run postgres_exporter against the database named by improv.database,
with --extend.query-path=postgres_exporter-queries.yaml, and as a role
that has USAGE on schema improv, SELECT on its tables, and
pg_monitor.
The HTTP server
improv-server (The HTTP Server) serves one model store.
- Health:
GET /healthreturns200 okwithout a token. It says the process is up and accepting requests. It does not open the store, so pair it with a periodic authenticatedGET /measuresif you need to know the model loads. - Scheduler:
GET /scheduler/status(token required) returns{"enabled", "tick_secs", "last_tick_refreshed", "last_error"}whenIMPROV_SCHEDULER=1. Alert on a non-nulllast_error. - Logs: plain lines on stderr, with no timestamps, so capture stderr with your supervisor (systemd's journal adds the time):
| Line | Meaning |
|---|---|
improv-server listening on <addr> | started |
auth disabled: set IMPROV_API_TOKEN to require a bearer token | open mode (loopback, or IMPROV_ALLOW_OPEN=1) |
improv-server: <error> | startup failed (bad address, refused open mode); it exits |
accept: <error> | a connection could not be accepted (often file descriptors exhausted) |
request <id>: internal error: <detail> | a 500; the client got {"error": "internal error", "request_id": <id>} with the same id |
scheduler disabled (set IMPROV_SCHEDULER=1 to enable) | the default |
scheduler: refreshing due CALL measures every <n>s | scheduler on |
scheduler: IMPROV_EXTFN_ALLOW unset; no function will run / cannot read IMPROV_EXTFN_ALLOW … | scheduler on but nothing allowed |
scheduler: WARNING skipping measure <id>: its function is not in the IMPROV_EXTFN_ALLOW allowlist | a function's digest is not allowed |
scheduler: [<t>s] refreshed measure <id> (<n> cells) | a refresh ran |
scheduler: measure <id> refresh failed: <error> / scheduler: tick error (continuing): <error> | a refresh failed; the server keeps running |
- Signals in responses: 409 means another writer saved since the server loaded (the next request reloads). 408 and 413 mean the request limits were hit. A rising 409 rate means two writers are fighting over one model.