Runbook — routine database maintenance

Audience: operators of a Stella Ops installation. Short version: there is nothing for you to do. Routine database maintenance — retention pruning and planner-statistics refresh — runs nightly through the product’s own scheduler on every installation. This page exists so you can see what it does, confirm it is running, and change it if you want to.

Owner ruling, 2026-08-10: Stella Ops must be self-serviceable. No installation may depend on an operator (or on us) running psql rituals or authorizing routine retention. If you find yourself hand-running SQL to keep a database from growing, that is a defect — please report it.

What runs, and when

ScheduleDefaultJob kindWhat it does
nightly-db-maintenance0 1 * * * (01:00 UTC)database-maintenanceAsks each configured service to maintain its OWN database.

01:00 is deliberately before the 02:00 nightly scan: reclaim first, then scan against a database that is not carrying the backlog.

Each service’s pass does two things, both bounded:

  1. Retention pruning — reclaims event envelopes that every consumer has durably processed AND that are older than the service’s declared window. Nothing unconsumed is ever deleted; see the eventing contract.
  2. ANALYZEof the hot tables, so the query planner’s statistics describe the table as it now is.

It never takes an exclusive lock. VACUUM FULL and CLUSTER take an ACCESS EXCLUSIVE lock on tables every consumer touches on every event; a nightly job must never be able to freeze the estate on its own initiative. If you ask for one (vacuum: true), the service answers vacuumPerformed: false and says why, rather than silently doing less than you asked.

Is it running?

The trigger loop says which mode it is in at startup, so “nothing fires” is never a mystery:

docker logs stellaops-scheduler-web 2>&1 | grep -i "cron triggering"

Then look at the runs themselves. A cron run is an ordinary run: it appears in GET /api/v1/scheduler/runs, it can be cancelled with POST /api/v1/scheduler/runs/{runId}/cancel, and it produces an audit event — exactly like a run you started yourself.

Turning it on

As of this release the loop ships DISABLED by default. That is a staging posture for the first supervised activation, not the intended end state: the ruling is nightly-by-default on every installation, and a later release flips it. Enabling it wakes every schedule that already exists on your estate, so do it deliberately and watch the first night.

# devops/compose/.env
SCHEDULER_TRIGGER_ENABLED=true

Then recreate the scheduler:

docker compose … up -d --force-recreate --no-deps scheduler-web

Changing it

Everything below is ordinary configuration; none of it needs a rebuild.

SettingDefaultMeaning
Scheduler__Worker__Trigger__EnabledfalseMaster switch for ALL cron scheduling, not just maintenance.
Scheduler__Worker__Trigger__PollInterval00:00:30How often the loop looks for due schedules.
Scheduler__Worker__Trigger__MisfireGrace00:10:00How late a missed fire may be and still run. See below.
Scheduler__Maintenance__Services__N__NameAn adopter’s name.
Scheduler__Maintenance__Services__N__BaseAddressThat service’s API base address.

To change when maintenance runs, edit the schedule itself — it is a normal schedule:

PATCH /api/v1/scheduler/schedules/sys-default-nightly-db-maintenance
{ "cronExpression": "0 3 * * *" }

Your edit survives restarts. The system schedules are created once if absent and never overwritten.

There is no connection string here, and that is deliberate. The scheduler never opens a connection to any service’s database; it asks each service to maintain its own (CoC §8.2). A service that has not implemented the maintenance contract simply is not listed.

The misfire grace, in plain terms

If the scheduler is down when a job was due, should it run late or wait for the next slot? MisfireGrace is that answer, and it bounds catch-up to at most one run per schedule — never a backfill of everything missed.

Reading the result

The job logs one line per adopter, with numbers the owning service measured against its own database:

Maintenance completed for 'vulnerabilities': 50000 row(s) reclaimed, 41943040 byte(s) released.

Two things that look like problems and are not:

Running it now, by hand

Not required — but if you want a pass immediately, trigger the schedule through the ordinary run API rather than touching the database:

POST /api/v1/scheduler/runs
{ "scheduleId": "sys-default-nightly-db-maintenance", "reason": { "trigger": "manual" } }

Or call one service’s maintenance directly (needs ops.health):

curl -sX POST -H "Authorization: Bearer $TOKEN" \
  https://stella-ops.local/api/vulnerabilities/v1/maintenance/runs \
  -H 'content-type: application/json' -d '{"reason":"manual"}'

If a database is still growing

  1. Check the loop is enabled and the schedule is not paused.
  2. Read the service’s own log. The retention pruner explains itself: it names the consumer holding the floor when a stream cannot be reclaimed (consumer '…' holds the floor at seq 0), and says when a stream is bounded by the window alone.
  3. A stream held by a consumer that has stopped reporting is logged as a lapsed registration, by name. That consumer is either dead (unregister it) or stuck (fix it) — the growth is attributable either way.

Do not hand-run VACUUM FULL to resolve this. If you believe you need to, that is the defect worth reporting: the bound belongs in product code.