⚠️ PostgreSQL support is EXPERIMENTAL. It is not yet recommended for production. SQLite remains the supported default, and is what every released deployment has run on.What "experimental" means here, concretely:
- The backend passes the full unit suite and an end-to-end smoke test (boot, migrations, setup, login, agent CRUD, settings, audit), but it has no production soak time — no long-running deployment, no load testing, no failover or connection-drop testing.
- There is no SQLite → PostgreSQL data migration path (see below), so adopting it means starting from an empty database.
- The dual-backend abstraction is young. Expect the sharp edges to be in dialect-specific SQL (JSON operators, LIKE collation, time bucketing) and in transaction semantics, which differ genuinely between the two drivers.
Use it to evaluate multi-instance deployment, or on a non-critical instance. Report issues rather than assuming a behaviour difference is intended.
a2wave runs on SQLite (default, supported) or PostgreSQL ≥ 9.6
(experimental). The backend is chosen by DATABASE_URL alone — a postgres://
or postgresql:// scheme selects PostgreSQL, anything else is treated as a
SQLite file path. There is no separate driver switch to keep in sync.
# SQLite (default) — a file path, relative to the API working directory
DATABASE_URL=./data/a2wave.db
# PostgreSQL
DATABASE_URL=postgres://a2wave:a2wave@localhost:5432/a2wave
DATABASE_POOL_MAX=10 # optional, default 10SQLite is the right default, and the only one currently supported. One container, one file, no external dependency, and it comfortably handles a single-instance deployment. Unless you specifically need what PostgreSQL offers below, stay on SQLite.
PostgreSQL targets multi-instance deployments. SQLite is a single file on a single filesystem, so two API replicas cannot share it safely. If you run more than one replica behind a load balancer — or you want your existing database backup, replication and monitoring to cover a2wave — PostgreSQL is the direction, with the experimental caveat above.
PostgreSQL shares database state only. SCM checkouts and Git worktrees remain
filesystem state: the Compose a2wave-workspace named volume is appropriate for
one API container, while replicas must mount the same RWX-capable PVC at the same
SCM_STORAGE_ROOT path. Node-local volumes would give each replica a different
checkout even though they share the same SCM Source row.
Stop every API replica before applying these migrations, then start only the upgraded version. Two independent reasons, either of which is sufficient:
- An older remover deletes workspace-removal reservations by stable id alone
and can erase a newer attempt's
attempt_tokenfence. - A pre-heartbeat replica writes no
instance_heartbeatsrow. Upgraded peers read a missing row as "dead" once past their startup grace window, so they would reclaim leases and worktrees out from under a replica that is very much alive.
Both failure modes are silent, so mixed-version operation is unsupported even briefly.
Run and Evaluation admission persists an SCM workload lease in the same transaction that snapshots the Agent binding. This prevents another replica from unbinding the Agent or reclaiming its checkout while the workload is queued, executing, cancelled-but-exiting, or cleaning up. Queue capacity decisions are also made under the same cross-replica transaction lock.
Liveness comes from heartbeats, never from age. Every process renews an
instance_heartbeats row every 30s and deletes it during graceful shutdown. A
mark's owner counts as dead only when it has no row, stopped beating past five
minutes, or booted after the mark was written (a reused instance id — the same
HOSTNAME across container restarts). Age alone proves nothing: multi-repository
Git work and filesystem cleanup routinely outlive any per-command timeout.
Three guards keep this honest, and they are the reason manual reconciliation is now the exception rather than the routine:
- Recovery of peers is withheld for one staleness window after boot, so the empty heartbeat table right after an upgrade is not read as "everyone died".
- A process fail-stops before peers may reclaim it. Four minutes without a successful renewal irreversibly pauses admission/promotion and starts global graceful shutdown; active CLIs are terminated and the process exits before peers use the five-minute death threshold. A late renewal cannot revive the old ownership. The one-minute margin, rather than a distributed epoch on every filesystem call, is the deliberate operational trade-off.
- Recovery re-checks liveness immediately before its guarded claim. Once an owner reaches the five-minute peer threshold, the earlier fail-stop deadline makes that stale verdict monotonic for the old process lifetime; status and removal-token CAS still arbitrate concurrent recovery replicas.
On that basis a surviving replica automatically:
- fails workloads abandoned by a stopped instance (the Run gets a retryable
INSTANCE_STOPPED_DURING_EXEC, its A2A task is synced, the Evaluation task fails with anevaluation_task.executeaudit entry), then releases their leases. Run status/result/steps and Evaluation task/results/audit settle in one database transaction, so a failed write leaves the workload retryable on the next tick rather than terminal-but-incomplete; - adopts workspace-removal reservations with no live owner, re-runs the same occupancy decision every remover runs, and either finishes the removal or releases the row as obsolete. A failed attempt is disowned again so the next tick retries — that periodic tick is the retry loop.
A starting PostgreSQL replica still does not fail running/pending
workloads or reset syncing/indexing SCM rows on boot: another replica
starting says nothing about a peer. Recovering a peer's work is the
heartbeat-driven reaper's job, not startup's. SQLite keeps automatic restart
recovery because it has only one API process, where a restart does prove the
predecessor is gone.
Draining workloads before intentionally removing a replica is still the clean path — graceful shutdown releases leases and removes the heartbeat row immediately, instead of leaving peers to wait out the staleness window.
With the reconciler in place this should not be needed; reach for it only when a reservation is visibly stuck past several sweep intervals and you have confirmed no replica is still working on it. Never delete by age or by source. Record both values first:
SELECT id, attempt_token, owner_instance_id, attempt_started_at
FROM scm_workspace_removals
ORDER BY attempt_started_at;Check the owner against live processes before touching anything:
SELECT id, started_at, heartbeat_at, now() - heartbeat_at AS since_beat
FROM instance_heartbeats
ORDER BY heartbeat_at DESC;A row whose owner_instance_id still appears here with a recent heartbeat_at
is live — leave it alone. Otherwise release only the exact observed attempt:
DELETE FROM scm_workspace_removals
WHERE id = '<observed id>' AND attempt_token = '<observed attempt_token>';If zero rows are affected, the reservation changed after inspection; stop and re-investigate instead of broadening the predicate.
PostgreSQL makes shared state correct; it does not make in-memory caches cluster-aware. Three of them matter, in descending order of impact:
1. The settings cache (lib/settings-cache.ts) — the big one. Settings reads
are served from an in-process snapshot, and only the replica that handles the
write calls refreshSettingsCache(). So after an admin saves on replica A,
replicas B..N keep serving the old values until they restart. That covers the
auth policy, SSO config, retention windows, attachment limits, OpenTelemetry
export settings (the exporter itself is also per-process — see otel.md),
and security switches such as settings.artifacts.requireAuthForDownload.
This is a behaviour change introduced by the async migration — settings used
to be read from the database on every access. It is the single strongest reason
not to run multiple replicas in production yet, and a large part of why the
backend is labelled experimental. A short TTL or LISTEN/NOTIFY invalidation is
the obvious fix and is not implemented.
2. The OIDC config cache invalidates only on the process that handled the
save; the 1s TTL in src/lib/oidc.ts bounds the divergence.
3. Feishu long-connection status (GET /api/agents/feishu-connections)
reflects only the instance answering the request — the response's meta.scope
says so explicitly.
4. In-process locks are per-replica. Every remaining withKeyedLock key — for
example skill reupload — serialises callers within one process only. SCM workload
admission, Agent binding mutation, and Run/Evaluation queue claims are the important
exceptions: they use the cross-dialect SCM mutation transaction, backed by a
PostgreSQL transaction-scoped advisory lock. A queued or active workload also holds
a durable source lease until its process and workspace cleanup finish.
a2wave setup can select the PostgreSQL backend at install time:
# External server (validated as a postgres:// / postgresql:// URL)
a2wave setup --yes --database-url postgres://a2wave:pw@db.internal:5432/a2wave
# Bundled postgres:16-alpine sidecar; the password is generated into the
# install's .env (0600) and wired into DATABASE_URL
a2wave setup --yes --with-postgresNote
These setup flags were added after CLI v0.7.2 and are not present in the
published a2wave@0.7.2 package. Confirm that a2wave setup --help lists
--with-postgres before using them.
The generated compose file reads DATABASE_URL=${DATABASE_URL:-/app/data/a2wave.db}
from the install's .env, so switching an existing install is a one-line .env
edit followed by docker compose up -d. The two flags are mutually exclusive,
and both are rejected with --upgrade (an upgrade never rewrites .env).
Remember that inside the container localhost is the container itself — an
external database on the host machine is reached as host.docker.internal
(Docker Desktop) or the host IP. On a PostgreSQL install, setup --upgrade's
data-volume backup does not cover the database; take a pg_dump first.
The bundled PostgreSQL service sits behind a compose profile, so the default
docker compose up stays a single container:
# PostgreSQL requires an explicit AUTH_SECRET: a generated one is private to the
# instance that made it, and the entrypoint refuses to start rather than hand
# replicas keys they cannot share. Generate a real 64-character value — pasting
# the command text itself yields 22 characters and fails the 32-character floor.
echo "AUTH_SECRET=$(openssl rand -hex 32)" >> .env
echo "DATABASE_URL=postgres://a2wave:a2wave@postgres:5432/a2wave" >> .env
docker compose --profile postgres up -dThe service is pinned to postgres:16-alpine (override with POSTGRES_VERSION).
16 is the version a new deployment should choose; 9.6 is merely the oldest
server a2wave still runs against, for operators stuck on a legacy instance.
postgres in that URL is the compose service name — it resolves on the
compose network and nowhere else. But .env is loaded by every a2wave process,
containerised or not, so a DATABASE_URL committed there is also what
pnpm run dev and pnpm db:migrate read on the host, where the name does not
resolve. One variable cannot hold both correct values:
| Consumer | Correct value |
|---|---|
| Container | postgres://a2wave:a2wave@postgres:5432/a2wave |
| Host process | ./data/a2wave.db (the built-in default) |
Compose reads .env only to forward the value inward, and already falls back
(${DATABASE_URL:-/app/data/a2wave.db}). So leave the variable out of .env
and set it where only the container sees it:
# .env — leave DATABASE_URL commented out; host commands then use the SQLite default
# Pass it per command … (AUTH_SECRET must come along; see above)
DATABASE_URL=postgres://a2wave:a2wave@postgres:5432/a2wave \
AUTH_SECRET="$(openssl rand -hex 32)" \
docker compose --profile postgres up -d# … or once, in docker-compose.override.yml, which host commands never read
services:
api:
environment:
- DATABASE_URL=postgres://a2wave:a2wave@postgres:5432/a2wave
# Same value on every replica — see "Startup recovery is not replica-aware".
- AUTH_SECRET=<a real 64-character value from `openssl rand -hex 32`>Host runs then fall through to ./data/a2wave.db — a per-worktree SQLite file,
matching the worktree isolation the repo already assumes.
Shell exports take precedence over
.env(process.loadEnvFiledoes not overwrite an already-set variable), soexport DATABASE_URL=...leaks into host commands in the same shell. Prefer the per-command prefix or the override file.
To point a host process at the containerised database instead, publish the
port (the compose file deliberately does not) and use localhost — never the
service name.
The database port is deliberately not published to the host — the API reaches
it over the compose network. Binding 5432 would expose the database, with its
default password, to anything that can reach the machine. Change
POSTGRES_PASSWORD before using this outside a local trial.
The two dialects keep separate migration lineages, and this is deliberate:
| Backend | Directory | Config |
|---|---|---|
| SQLite | apps/api/drizzle/ |
drizzle.config.ts |
| PostgreSQL | apps/api/drizzle-pg/ |
drizzle.pg.config.ts |
They cannot share a folder. The generated DDL genuinely differs (jsonb vs
text, timestamptz vs integer epochs, bigint for the 64-bit columns), and
the SQLite lineage carries ~100 migrations of history that a fresh PostgreSQL
database must never replay. A PostgreSQL deployment starts from the current
schema as migration 0.
pnpm db:migrate reads DATABASE_URL and applies the matching lineage
automatically. Both directories ship in the Docker image.
Every replica runs the migrator at boot, and drizzle's PostgreSQL migrator takes
no lock of its own. Without coordination a three-replica rollout replays the same
DDL three times over: the losers fail on relation ... already exists and
process.exit(1) with a message telling the operator never to retry against a
database in an unknown state — a rollout that half-fails and then refuses to
self-heal, against a database that was actually fine.
runPostgresMigrations therefore checks a dedicated client out of the pool,
takes pg_advisory_lock(<fixed key>) on it, runs the migrator on that same
client (drizzle(client)), and unlocks + releases in finally. Three details
are load-bearing:
- Session scope, one connection.
pg_advisory_lockis held by the session, so the lock and the migration have to be issued on a connection this code owns for the duration. Locking through the shareddbhandle would route the two statements to arbitrary pooled connections and protect nothing. - The DDL rides the locked client, not the pool.
DATABASE_POOL_MAXaccepts1, and at that size the lock holder is the pool: a migrator issuing its DDL throughdbwould queue for a connection that only comes back when the migration ends. Boot deadlocks silently — no error, no timeout. - Not the
xactvariant.pg_advisory_xact_lockreleases at commit, and the migrator opens and commits its own transactions — the lock would drop mid-migration.
Operationally: a replica that is waiting logs
Waiting for the PostgreSQL migration advisory lock... and blocks there, so a
boot that appears to hang during a rollout is usually just the second replica
waiting for the first to finish migrating. The key is visible in pg_locks as a
advisory lock. It does not make the release in the warning above rolling —
the two reasons given there are about mixed-version behaviour, not concurrent
DDL.
Adding a schema change means regenerating both:
# 1. edit apps/api/src/db/schema.sqlite.ts
pnpm db:generate # SQLite migration
pnpm db:generate:pg # regenerate schema.pg.ts from the SQLite schema
pnpm db:generate:pg:migration # PostgreSQL migration
pnpm db:migrate # apply to whichever backend DATABASE_URL points atschema.pg.ts is generated, not hand-written — that is what keeps the two
dialects from drifting. Do not edit it directly.
a2wave ships no SQLite → PostgreSQL data migration tool. Pointing
DATABASE_URL at a PostgreSQL server gives you an empty a2wave, not a copy of
your SQLite data. Choose the backend when you deploy; moving an existing
installation means exporting and re-importing your Agents (agent export/import
round-trips through the API) or writing a one-off migration yourself.
Most application code is already dialect-neutral, because every query goes
through Drizzle and every DB call is async. Four things still need care:
-
Transactions must use
withTransaction(src/db/transaction.ts), neverdb.transaction()directly. better-sqlite3 is synchronous and rejects an async callback outright withTransaction function cannot return a promise, so a rawdb.transaction(async …)is a guaranteed runtime failure on the default backend. The helper branches on the dialect and documents its atomicity argument.A write issued outside any transaction, on a path that can run concurrently with one, must go through
runExclusive(same module). SQLite shares one connection process-wide, so a plaindb.insert(...)that happens to run while another request holds aBEGINjoins that transaction and is erased if it rolls back — after the write's own request already returned success. Everyawaityields the event loop (await db.insert(...)included, since drizzle settles through the microtask queue), so this window is ordinary, not exotic.runExclusiveserialises on the transaction key and waits instead of joining.logAudit/logBackgroundAuditalready do this; dropping an audit entry this way breaches Iron Rule 5. On PostgreSQL it is a pass-through — each caller has its own pooled client. -
Never read driver-specific result fields. better-sqlite3 reports
changes, node-postgres reportsrowCount. Use.returning()and count the rows instead — this is what every compare-and-set guard in the codebase does. -
JSON, LIKE and time bucketing go through the dialect helpers (
src/db/dialect-runtime.ts):jsonExtract,jsonSet,jsonPathIsAbsent, the case-insensitive LIKE wrapper, and the time-bucket builder. Rawjson_extract(...)SQL is SQLite-only. -
Unique-violation detection is dialect-aware — use the shared helper rather than matching on
UNIQUE constraint failed, which is SQLite's wording.
curl localhost:3502/api/health/ready # {"status":"ready"} once seeding finished
curl localhost:3502/api/health # checks.database.ok must be true/api/health/ready returns 503 starting until boot-time seeding completes.
Point your readinessProbe at it so a rolling update never routes traffic into
the window where the port is bound but settings have not been written yet.