Databases: configuration, migrations, transfer, shards, failover
This page is for whoever runs a merkhain instance or relay on something other than the defaults: choosing SQLite or PostgreSQL, upgrading schemas, moving data between backends, running a sharded relay and keeping it available. Everyday status, repair and owner recovery are in Operations.
- Backends and layouts
- Configuration
- First installation
- Upgrades and migrations
- Moving an instance from SQLite to PostgreSQL
- Moving a relay from SQLite to PostgreSQL
- Relay shards, replay protection, request routing
- Moving pools and replay buckets
- Several relay processes and operator sessions
- Backup and restore
- High availability and failover
- Search on SQLite and PostgreSQL
- Capacity
- Recovery from failed operations
- Limitations
Backends and layouts
merkhain supports SQLite and PostgreSQL 16 and 18, nothing else. Both drivers are pure Go, so the released binary stays static on every platform.
| Database | Default | PostgreSQL layouts |
|---|---|---|
Instance (serve local, hooks, MCP, CLI) |
SQLite <data_dir>/merkhain.db |
one database (or schema) per instance |
Relay (serve remote) |
SQLite <data_dir>/relay.db, hosting every relay role |
one database hosting every role, or a control database plus named pool and replay shards |
- SQLite is a file next to the binary's data: WAL,
synchronous=FULL, one writer at a time. It needs no server and is the right choice for one person's instance and for small relays. - PostgreSQL suits instances whose data should live on a managed server, and relays that need several relay processes, more concurrent writers, replication or shards. Concurrent writes to different pools do not wait for each other there.
- An instance database belongs to exactly one instance (one device). Never point two instances at the same database or schema, and never copy a database between devices: devices share memories through pools.
The relay's databases have roles:
| Role | Holds |
|---|---|
control |
shard registry, pool and replay-bucket routes, creation grants and pool provisions, enrollment locators, move jobs, published statistics |
pool |
each pool's ordered records, membership, devices, enrollments, key wraps, labels, counters and move journal |
replay |
consumed request nonces (the replay window) and replay-bucket fences |
A SQLite relay is one database with all three roles. A PostgreSQL relay has a control database that hosts every role no shard hosts, so one PostgreSQL database can host them all, or shards can take the pool and replay roles.
Configuration
The database section of config.json selects the databases. Every field is optional.
{"database": {
"local": {"url": "postgres://bz:secret@db1:5432/merkhain_alice"},
"relay": {
"control": {"url": "postgres://bz:secret@db1/relay", "max_conns": 32},
"shards": [
{"name": "pool-a", "roles": ["pool"], "url": "postgres://bz:secret@db2/pools"},
{"name": "replay-a", "roles": ["replay"], "url": "postgres://bz:secret@db3/replay"}
]
}
}}
| Setting | Environment | Meaning |
|---|---|---|
database.local.url |
MERKHAIN_DATABASE_URL |
instance database: sqlite:///abs/path.db or postgres://… |
database.relay.control.url |
MERKHAIN_RELAY_DATABASE_URL |
relay control database: SQLite (then the only relay database) or PostgreSQL |
database.relay.shards[] |
— | named PostgreSQL databases hosting pool and/or replay |
max_conns |
— | PostgreSQL connection pool size per process and database (default 16, at most 1024) |
max_idle_conns, conn_max_lifetime, conn_max_idle_time |
— | PostgreSQL idle and recycling limits (durations such as "15m") |
-
URLs. SQLite URLs need an absolute path and take no query; their connection settings are fixed. PostgreSQL URLs are passed to the driver (
postgres://orpostgresql://, withsslmodeand the other libpq parameters). An instance database that is not on a Unix socket or loopback address needssslmode=verify-full(withsslrootcertunless the server's CA is in the system store); other modes are refused (see Security). Asearch_path=NAMEparameter selects an existing schema, so several merkhain databases can share one PostgreSQL database as separate schemas. -
Privileges. The configured user creates and alters the tables (first installation and
migrate up), so it must own its schema or haveCREATEon it. On PostgreSQL 15 and later,publicgrants that only to the database owner. -
pg_trgm. The local role's schema needs thepg_trgmextension (shipped with PostgreSQL, trusted since PostgreSQL 13) in a schema on thesearch_path. When the database has none, the baseline installs it into the current schema, which needsCREATEon the database; otherwise have a superuser runCREATE EXTENSION pg_trgmin a schema on thesearch_pathfirst. The baseline refuses an installation thesearch_pathcannot see. A user without either fails the first start with nothing applied:migrate: local migration 407 failed (nothing was applied): ERROR: permission denied to create extension "pg_trgm" (SQLSTATE 42501) migrate: local migration 407 failed (nothing was applied): ERROR: pg_trgm is installed in schema ext, which is not on the search_path ({ek,public}): add it to the search_path of the database URL (SQLSTATE P0001)The database stays empty, and every command (agent hooks too) fails with that error until the extension is available. After a superuser runs
CREATE EXTENSION pg_trgm SCHEMA public(or in another schema on thesearch_path), the next command initializes it. -
Credentials stay in the configuration.
merkhain config,migrate status,status, the console and error messages show URLs redacted (no password), and the console showspostgres://host:port/dbnameonly. Keepconfig.jsonowner-only, or put the URL into a root-owned environment file of the service (see Relay). -
Prefer
config.jsonover the environment for the instance database. Capture hooks and MCP servers are started by Claude Code and Codex, not by the service, and only seeMERKHAIN_DATABASE_URLif the agent's environment has it. A URL inconfig.jsonreaches every process. -
Connection budget. Each process opens up to
max_connsconnections to each of its databases. Size PostgreSQL'smax_connectionsfor every relay process,migrate/shard/movecommand and instance process that runs at the same time. -
Choose the layout before the first start. Each database is listed once, and roles that share a database are migrated together and are never added to an initialized database later. Adding a shard for a role later moves that role off the database that hosted it before (see Limitations).
First installation
A genuinely empty database is initialized automatically, by the first command or service that opens it. On PostgreSQL an advisory lock, and on SQLite a cross-process migration lock, make concurrent first starts safe: one initializes, the others wait and then use the result. A database that is not empty but has no merkhain schema is refused.
-
Instance: set the URL, then follow Local memory.
merkhain statusreportsdatabase.driver. -
Single-database relay (SQLite or PostgreSQL): set the URL and start
serve remote. -
Sharded relay: create the empty databases, then initialize and register them before any relay serves traffic:
merkhain migrate up --relay # control and every configured shard merkhain shard register pool-a replay-a # all shards at once merkhain shard status merkhain serve remote # on every relay hostRegister all replay shards in one
registercall: the first registration of a replay shard places all 4,096 replay buckets, spread over the replay shards it registers; shards registered later get no buckets until a bucket move. Configure the replay shards before serving traffic (see Limitations).
packaging/ci/postgres-smoke.sh runs these steps against PostgreSQL with the release binary.
Upgrades and migrations
Who applies schema migrations depends on the database's role, on SQLite and PostgreSQL alike:
- Automatic: the instance database and the Personal sync relay. The next normal open after a binary upgrade (any
command, a capture hook, the MCP server,
serve local) applies pending migrations. No command and no shutdown are needed. - Explicit: standalone relay databases (
serve remote: the control database and every shard). After an upgrade,serve remoteand the relay commands refuse a database with pending migrations and namemerkhain migrate up.
merkhain migrate status # read-only: every configured database, its roles, versions, pending migrations
merkhain migrate up --local # the instance database (optional: the next normal open does it too)
merkhain migrate up --relay # the relay's control database and every shard
merkhain migrate up --database pool-a # one database: local, control or a shard name
Migrations of a database run in one transaction under its migration lock (a SQLite write lock, a PostgreSQL
transaction-scoped advisory lock), so a failure or crash leaves the database unchanged and the next open (or
migrate up) tries again; a ledger is never forced or downgraded automatically. Concurrent openers inspect the
database first, take no lock when it is already usable, and inspect it again under the lock, so each migration is
applied once. While a migration runs, other processes wait briefly for the lock: a capture hook at most its own time
budget, after which it fails retryably (exit 75), PostgreSQL connections at most their 10-second lock_timeout. On
PostgreSQL the schema change itself also takes table locks that ordinary queries wait for; the advisory lock only
coordinates migrators. Each role has its own history (migrate status shows state, version, latest, pending
and dirty per role, the database's policy and, while migrations are pending, a hint saying how they get applied).
Mixed versions. From the first release with automatic migrations (the bridge release) on, every migration keeps
the reads, writes and data semantics of older binaries working, so processes of the previous version keep running
against an upgraded database and can restart against it. Such a database is ahead for the older binary: a clean
ledger above the newest version it knows. It is healthy and usable; migrate up of the older binary leaves it
unchanged, and its migrate down and migrate force refuse it. Rolling an application back means running the older
binary against the upgraded database, never reverting the schema. Binaries released before the bridge release still
refuse a newer schema (schema too new); upgrade every process to the bridge release first. A database older than the
baselines is refused: see below.
The relay that Personal sync over Tailscale runs inside serve local keeps its own SQLite file,
personal-sync-relay.db in the data directory. It is not a configured database: migrate status lists it once it
exists, --relay leaves it out, and it upgrades automatically when serve local starts it
(merkhain migrate up --database personal-sync-relay does it right away).
Instance upgrade: see Install. Nothing needs to be stopped.
Relay upgrade (rolling):
- Back up every relay database (Backup and restore).
- With the new binary, run
merkhain migrate up --relayonce (from one host) while the old relay processes keep serving. Each database migrates atomically; if the command stops partway through several databases, run it again: databases already migrated are skipped. - Replace the binary on the relay hosts and restart them one at a time. To roll back, start the old binary again; it serves the migrated databases.
Run migrations as the service's user and with the service's configuration: with the shipped systemd unit
sudo systemd-run --pty -p DynamicUser=yes -p StateDirectory=merkhain-relay -E MERKHAIN_HOME=/var/lib/merkhain-relay /usr/bin/merkhain migrate up --relay (add -p EnvironmentFile=… if the unit uses one); with the container
docker run --rm -v merkhain-relay:/var/lib/merkhain [-e MERKHAIN_RELAY_DATABASE_URL=…] merkhain-relay migrate up --relay.
Long migrations. A migration that backfills existing data runs as one statement in the migration transaction, without progress output, and holds its locks until it commits. New migrations keep startup work short and move large backfills to resumable background work.
The recall comparison's tables
The recall comparison is opt-in and keeps its queue, memory_get followups and fuzzy vocabulary in its SQLite evidence
index, not in the local database. No migration of the local database goes with this, and nothing is dropped:
recall_comparisons, recall_followups and the fuzzy_* tables of migrations 320 and 405 stay for older binaries,
which keep reading and writing them. This binary never writes to them. serve local copies what the queue tables
hold into the index in bounded background batches (and goes on copying what an older hook still writes), while an
evidence index exists or comparison is on. The vocabulary is derived data and is rebuilt in the index. Evidence
records and judgments were already in the evidence root and do not move.
Databases below the baselines
Each role's history starts at a baseline, the schema its earlier migrations built, squashed at commit 7ccdaeea:
local 407, control 530, pool 520, replay 470. A database with any of those versions or newer opens normally: the
baseline does not touch it and its ledger stays valid, even if it was migrated step by step. An older one, including
a database from before the role ledgers existed (with only a schema_migrations ledger), is refused by every command
and left unchanged, with an error that names the commit.
To keep such a database, upgrade it first with a binary built from that commit: back it up, open it with that binary
(any normal command; merkhain migrate up for a standalone relay database), then install the current binary. The
upgrade notes for older migrations are in docs/guide/databases.md at that commit.
Upgrading to event payload retention (408)
Migration 408 adds four nullable columns (events.payload_pruned_at, events.payload_stub,
events.payload_restored_at, extraction_jobs.payloads_pruned_at), two partial indexes and a trigger on events. It
rewrites no rows and is additive. serve local removes event content only if you set retention.event_payload_days:
see Raw event retention.
Which older binaries this covers. No version has been tagged or released yet, so there is no bridge release to pin.
The range is what can open a current database: from 23df9e7c (the rename to merkhain, #550, which changed data paths
and migration ledgers without compatibility, and already holds migration 405) to bfedb0d4, the last commit before 408.
Automated tests run a real binary built from each end on SQLite and PostgreSQL (TestOlderBinaryReplaysPrunedTranscript);
commits between them were not run, though capture's comparison and queries do not change between them. Nothing older
was tested.
An older binary keeps capturing, extracting and serving recall against a database with pruned payloads. It shows pruned prompts with their author and an empty text, and does not know why they are empty.
It can also replay a transcript of a pruned session, comparing each record with the stored row byte for byte. So that
this stays a duplicate, and its source keeps advancing, the trigger puts the replayed payload back into the row just
before that comparison. The row is still pruned for current binaries and for extraction, no event is captured again
and the prompt search index stays empty, but the plaintext of a replayed event is back in the database until the
next retention pass of a current binary (hourly, and at the start of serve local), whatever
retention.event_payload_days now says. If you run only an older binary from then on, it stays there.
A replay that changes an event's kind, time or the pruned row's compared fields (author, agent_id, phase,
sidechain) is still a conflict, for both binaries, and changes nothing. One that changes only other content is not,
because the pruned row has nothing to compare it with.
migrate down --local drops the columns, the indexes and the trigger. Pruned payloads do not come back, and an older
binary's replay of a pruned transcript then reports event_identity_conflict and stops that source, because no
trigger answers its comparison any more. To go back without that, stay on the upgraded schema (older binaries accept
it) or restore the backup.
Down and force
merkhain migrate down --database pool-a --role pool [--steps N]
merkhain migrate force --database pool-a --role pool VERSION
migrate downreverts migrations newer than the baseline only. While a role's latest version is its baseline,migrate downhas nothing to revert. On a sharded relay,downandforceneed--database NAME(exactly one database). A database that hosts more than one role, such as a SQLite or single-database relay (control, pool and replay), also needs--role ROLE.migrate forcerecords a version after you repaired a schema another tool leftdirty. It is never run automatically and cannot repair a ledger thatmigrate statusrejects (unknown version, partially initialized); those need a restore. Adirtyledger above the binary's versions was left by a newer binary's failed migration; repair it with that binary.migrate downandmigrate forcerefuse a role that isaheadof the binary: only the binary that knows those migrations may revert or record them. Rolling an application back runs the older binary against the upgraded schema; to return to the exact earlier schema, restore the backup.
Moving an instance from SQLite to PostgreSQL
Pointing an existing instance at PostgreSQL by itself starts a new, empty database: the data in merkhain.db stays
where it is, while the device identity and pool membership in the configuration directory still refer to it. While
the PostgreSQL database holds no local data and <data_dir>/merkhain.db does, status reports
db_sqlite_data_left_behind and serve local logs a warning. If that file cannot be read, status reports
db_sqlite_data_left_behind_unknown instead, with sqlite_data_left_behind_reason: it may or may not hold data. Copy the data with merkhain transfer local:
-
Create an empty PostgreSQL database (or schema) owned by the configured user.
-
Stop every merkhain process using the instance:
serve local, and the agents whose hooks and MCP servers use it. -
merkhain migrate status --local: if the SQLite database has pending migrations, runmerkhain migrate up --localon it first. A database that isahead(migrated by a newer binary) is copied with that newer binary. -
Copy, verify and activate:
MERKHAIN_DATABASE_URL='postgres://bz:secret@db1/merkhain_alice' merkhain transfer local--todefaults to the configured local database (database.local.urlorMERKHAIN_DATABASE_URL), so the target's password stays out of the process arguments, which other local users can see.--torefuses a URL with a password (in its userinfo or apassword/sslpasswordparameter); with--to, keep the password inPGPASSWORDor~/.pgpass:PGPASSWORD=… merkhain transfer local --to 'postgres://bz@db1/merkhain_alice'.--fromdefaults to the SQLite instance database and--batch(rows per checkpoint) to 500. The transfer holds the source's write lock for the whole run, copies every table in checkpointed batches, keeps IDs, timestamps, bytes, event sequences, jobs, resolutions, ciphertext and sync cursors, restores the sequences, rebuilds the PostgreSQL search indexes (memory and prompt; migration 405's fuzzy vocabulary, which only older binaries use, starts empty and is rebuilt byserve localin the background), and compares row counts and content hashes of every table. Only then does it mark the target usable; until then every command andmigraterefuse it, andmigrate statusshows"transfer": "copying". Exit 75 means a writer still uses the source, or another transfer into the same target is running: stop it and run the command again. Run it again after any interruption too; it continues from the last checkpoint, and on a verified target it does nothing. -
Cut over explicitly: set
database.local.urlinconfig.jsonto the target. Startserve localand checkmerkhain status(database.driver: postgres) and amerkhain recall search.
The SQLite file is left as it was (SQLite may checkpoint its WAL into the main file) and is your way back until you delete it: switching the URL back returns to the data as it was at the transfer. Anything written on PostgreSQL after the cutover is not in it, and there is no transfer from PostgreSQL to SQLite.
Do not write the source between the first run and the cutover. A source changed after the first run is detected by its fingerprint and the transfer refuses the target; so does a target that some other process wrote to. In both cases drop the target schema (or database) and transfer again. Search result cursors from the SQLite instance are not valid on PostgreSQL; timeline cursors are. Identity and keys live in the configuration directory and are unaffected.
Moving a relay from SQLite to PostgreSQL
merkhain transfer relay copies a SQLite relay database offline into a PostgreSQL relay: a control database and the
pool and replay shards registered there. One PostgreSQL database registered as control works too, and so does a
control database hosting the replay role with only pool shards registered (it then keeps every replay nonce). A
PostgreSQL control database that hosts the pool role without being registered is not a target: register control as
a shard. Prepare the target like a new relay (First installation), then transfer and switch:
# 1. Back up relay.db, then stop `serve remote`. Nothing may write relay.db until the switch.
# 2. Configure database.relay (control and shards) in config.json, then:
merkhain migrate up --relay
merkhain shard register pool-a pool-b replay-a # every shard that should receive pools or replay buckets, in one call
merkhain transfer relay # --from defaults to <data_dir>/relay.db; --batch N (default 500)
# 3. Start `serve remote` with this configuration. That start is the cutover.
The transfer copies the creation grants and their receipts, pool provisions, enrollment locators (creating one for every enrollment stored before locators existed), and every pool's records, sequences and counters, memberships, devices, enrollments, key wraps and labels unchanged, so cursors continue and retried creations get their receipts. Each pool goes to the shard a new pool would be placed on (draining shards receive none), keeping its route generation; each nonce, expired ones included, goes to the shard of its device's replay bucket with its expiry, so requests seen by the SQLite relay stay replays. Statistics are rebuilt for the new routes; finished move jobs are not copied. The output is JSON with per-database row counts and hashes and the next step; a rerun after verification changes nothing.
The source is refused while it has an active writer (exit 75: stop it, retry), while it holds unfinished work (a live
or interrupted pool or bucket move, a pool move journal, a pool creation that was interrupted: start serve remote on
it once so it completes, a fenced pool or replay bucket, or rows in a table the transfer does not copy), or when its
schema is not current (migrate up --relay first). A target is refused when it is not migrated, has no active pool
shard, has replay buckets without a route (register the replay shards, or let the control database host the replay
role), holds relay data other than its registry, or a shard's identity does not match its registration; a refused
target is left untouched. Every database is marked before anything is copied, so neither serve remote, migrate
nor shard register runs on the target until the transfer is verified: shards first, control last. Each table is
copied in checkpointed batches; after an interruption, run the command again. Do not write the source or change the
shard registry in between: either is refused as another transfer; drop the target databases and start again. The
source keeps its content and can be started again instead of switching. Exit codes are those of transfer local: 0
verified, 2 usage, 75 retry, 1 otherwise.
Relay shards
The relay's control database keeps a registry of the databases hosting the pool and replay roles (configured shards,
and control when it hosts them). Migrate a shard first, then register it:
merkhain migrate up --relay # control and every shard
merkhain shard register pool-a # verify and activate; several names at once are fine
merkhain shard status # read-only: registered and configured shards, routes, probes
merkhain shard drain pool-a # no new pools on pool-a; existing pools stay
register refuses a database that is unreachable, whose schema is not current, or whose identity does not match: the
first registration stamps an identity into the database, so a name that later points at another database, or a
database configured under a second name, is detected. Registering a draining shard activates it again. A shard keeps
the roles it was registered with. Shards cannot be unregistered: drain a pool shard and move its pools away.
A new pool is placed once, on one of the active pool shards, and that route is stored. Registering more shards never
moves existing pools; only an explicit move does. The 4,096 replay buckets are placed on the active replay shards when
the first replay shard is registered and also stay where they are. serve remote routes every request through them
(see Request routing).
Creating a pool reserves the pool identity (and its creation grant) for one exact genesis in the control database, creates the pool on its shard, then publishes its route and the grant's receipt. A relay that stops in between completes the creation when it starts again, or when the client retries; until then the pool has no route and the grant cannot be used for anything else. Enrollment locators are published in the control database before the enrollment is stored.
Replay protection
Every signed device request consumes its nonce once, on the replay shard that owns the device's bucket (the 4,096
buckets above), whichever pool or relay process served it. A nonce transaction holds its bucket's fence in shared
mode, so requests never wait for each other unless they reuse the same nonce. Each replay shard also records the route
generation at which it owns a bucket; a relay with a stale route, or a shard that does not own the bucket yet, refuses
the request with a retryable unavailable error and records nothing. So does an unreachable control or replay
database, a bucket without a route, or a shard URL that now points at another database: replay protection fails
closed, and clients retry with a new nonce. The request's timestamp window is checked again inside the nonce
transaction, so a request that gets there after its window is refused as expired, even if its timestamp was fresh
when it arrived.
Nonces are deleted in the background one minute after they expire, once at startup and then every minute, in batches of at most 1,000 rows (10 batches per shard and round, so a backlog is worked off over several rounds). Requests no longer delete them. Several relay processes may prune the same shard. On SQLite each batch briefly takes the database's write lock. A control database that hosts the replay role itself (every SQLite relay, and a PostgreSQL control database when no shard hosts replay) serves every bucket until a replay shard is registered.
The first registration of a replay shard places all buckets at once. Before it does, it checks every configured replay database: none may hold a live nonce of a bucket placed on another database, or ownership left by earlier routes (a moved bucket). Such history means the control database was rebuilt, or another database served replay so far, and placing over it could accept nonces recorded elsewhere, so registration refuses to place anything. Restore the control database, or register the database that served replay alone and move buckets from it.
Request routing
serve remote serves each request from the database that is authoritative for it, without consulting the others:
- Pool requests (membership, records, rotations, enrollment offers and sponsor routes, key mailboxes) go to the shard of the pool's route in the control database. Pools without a route (created on a single-database relay, or before routes existed) are served by the control database when it hosts the pool role; otherwise they do not exist. A pool whose creation has not been published is not served.
- Enrollment-only requests (claim, claimant status and completion) resolve the enrollment's locator in the control database, then the pool's route. Only a control database hosting the pool role resolves enrollments stored before locators existed.
- Nonces go to the replay shard of the device's bucket (see Replay protection).
- Pool creation reserves in the control database, initializes on the destination shard and publishes there (see above); interrupted creations are resumed at startup and then every minute.
Any number of relay processes may serve the same databases; each caches routes and shard connections. Every pool
shard records the route generation at which it serves each pool, and every request checks it inside its transaction,
so a relay with a stale route (the pool was moved) refreshes it once, and a pool that is moving is answered with a
retryable 503 unavailable (Retry-After: 1) without reading or writing anything. A registered shard that is not
configured on a relay, unreachable (opening one is bounded by 10 seconds), not current, or whose stamped identity does
not match its registration fails only the requests of its own pools, the same way. A shard is verified again after it
failed, so a URL that now reaches another database fails closed. Clients retry with a new request.
Relay Administration's pages work on every relay backend and read only the database authoritative for them:
- The overview reads the control database: its schema state, grant counts and the published statistics (with their
freshness:
fresh,staleorunknown). The other configured databases are listed with their driver, roles and connection pool counters and apostgres://host:port/dbnameendpoint, never credentials or connection parameters; they are not queried. Only a SQLite control database also reports its file, WAL and page sizes; nothing runs a PRAGMA or probes a file on PostgreSQL. Relay-wide enrollment counts are shown only when every pool is stored on the control database; on a relay with pool shards they are reported as unavailable instead of reading every shard. Settings → Storage shows the same schema state, database list and SQLite sizes. - Creation permissions (creation grants) are issued, listed and revoked in the control database on SQLite and PostgreSQL alike.
- The Teams page reads the control database. When the pools live on pool shards it lists the published pool summaries: a new team appears, and its counts change, after its shard's next statistics collection (one minute by default), and the list has no update times.
- A team's detail, members, history and invitations (enrollments) are read on the pool's shard. A pool without a statistics row reports its record counts as unknown.
Moving pools
A pool moves from its shard to another registered, active pool shard while relays keep serving it:
merkhain move pool pol_... pool-b # start the move and run it to completion
merkhain move status # live moves; `move status ID` shows one move and its progress
merkhain move resume ID # continue an interrupted move (stopped, crashed, or failed)
merkhain move cancel ID # undo a move that has not fenced its source yet
merkhain move cleanup ID # remove a done move's copy from the source shard
The move first fences the destination so it refuses the pool, then journals the keys of the pool's changed rows on
the source and copies the pool in pages while it is written. It applies the journal until it is drained and verifies
the copied records. Then it fences the source, which waits for in-flight writes: from here the pool answers 503 unavailable (clients retry) until the move completes. It makes the destination equal to the source and verifies the
membership, devices, enrollments, key wraps, records, record counter and deduplication keys, advances the route in the
control database, and activates the destination. Record sequences and client cursors are unchanged; other pools on
either shard are not affected.
Every step is recorded in the control database and is idempotent, so move resume continues an interrupted move
from where it stopped, from any machine with the relay configuration. Runs of the same move may overlap. Before the
source is fenced, move cancel stops the journal and removes the partial copy; afterwards a move can only be resumed,
so a failed or interrupted move keeps that pool unavailable until it is resumed. At no point do two shards accept
writes of the pool.
The source keeps its fenced copy, so relays that still route to it are redirected, until move cleanup. Cleanup
refuses while the source holds the pool again (it moved back). Moving a pool back to a shard that still has an old
copy replaces that copy. Moves need a pool route: pools without one (stored on a control database that hosts the pool
role) cannot move, and a SQLite relay is a single database. The pause while the pool is fenced grows with the rows
changed during the move and the rows of the pool other than records (records are verified before the fence). Until
cleanup, per-shard row counts include the fenced copy; the published statistics count a pool on its routed shard
only.
A relay binary older than pool migration 634 does not know removal manifests and would lose them in a move. It can no
longer finish one: on a pool with manifests its fence fails with relay_manifest_fence before the point of no return and
the pool keeps serving from the source, so cancel the move or run it with a current binary. A pool without manifests moves
as before. A move a current binary took part in cannot be activated by an older one (relay_manifest_guard); if an older
binary has already switched the route, the pool is unavailable until a current binary resumes the move, which restores
the manifests from the fenced source; move cleanup refuses a move that is not done, so that source stays. Upgrade
every relay process before you move pools.
Moving a replay bucket
A bucket moves to another registered, active replay shard while relays keep serving it. Buckets are numbered 0 to
4095. move status, move resume ID and move cancel ID work as for pools. move status lists live bucket moves
under bucket_moves:
merkhain move bucket 1234 replay-b # start the move and run it to completion
The move is a job in the control database that resumes from its last step after a crash or failure:
- The destination fences the bucket, so it refuses the bucket's nonces until the end.
- The source's records of the bucket are copied to the destination online. The bucket stays available.
- The source fences the bucket, after waiting for its in-flight requests. From here the bucket is unavailable and the move can only go forward; before, it can be cancelled.
- The records are merged again from the frozen source, keeping the later expiry where both shards have a record, and checked.
- The bucket's route moves to the destination at the next generation.
- The destination activates the bucket.
Only the moving bucket is unavailable, from step 3 until step 6, and requests for it get a retryable 503. Relays
with a stale route refresh it. The source never accepts the bucket again unless a later move brings it back at a
higher generation. A move deletes nothing and has no cleanup step: the source's copy expires and is pruned like any
other record, pruning keeps running during a move, and the source's fence row stays as the forwarding tombstone. A
cancelled move leaves the destination fenced with the records copied so far; both only refuse more, and a later move
to that destination starts from there. Selecting a bucket's records reads the whole nonce table of the source (once
online, and once while the bucket is unavailable), which holds only the replay window, so the pause grows with the
source's nonce count.
Several relay processes
Any number of serve remote processes, on one host or several, may serve the same databases (PostgreSQL only; a
SQLite relay is one process on one host). Each process caches routes and shard connections and refreshes them on its
own, so no coordination between processes is needed. For a deployment behind a load balancer:
- Give every process the same database configuration, including every shard: a shard missing from one process's configuration fails the requests of its pools on that process.
- Give every process the same operator token. Each process creates
<config_dir>/relay-operator-tokenon its first start if it is missing; copy one file to every host before their first start (or replace the others and restart). The token only authorizes pool creation. - Keep clocks synchronized (NTP). Request signatures and nonce expiry are checked against each process's clock.
- Operator console sessions are node-local. An operator's browser session (
/ui/) exists only in the memory of the process that issued it (8 hours, at most 64 per process) and ends when that process restarts. Route the console with session affinity: a sticky cookie or source-address hash for/ui/, or send/ui/to one designated process. Without affinity the console signs the operator out at random. Creation grants are stored in the control database and work from every process. The device API (/v1/) needs no affinity. - Every process collects pool statistics from every shard each minute and prunes nonces on every replay shard. Both are idempotent; the work grows with the number of processes.
- Budget connections: processes × databases ×
max_conns.
Backup and restore
Back up every database before migrate up, transfer and anything else on this page that changes data.
SQLite (instance and relay): online sqlite3 FILE "VACUUM INTO '/backup/…'", or stop the service and copy the
file with its -wal and -shm. Details: Operations and Relay.
PostgreSQL, one database: pg_dump takes a consistent snapshot while merkhain runs:
pg_dump -Fc -d 'postgres://bz@db1/merkhain_alice' -f merkhain-$(date +%F).dump
pg_restore -d 'postgres://bz@db1/merkhain_alice_restored' merkhain-$(date +%F).dump # into an empty database
Restore into an empty database (or schema) with all merkhain processes using it stopped, point the configuration at
it, and check merkhain migrate status before starting. A restored database must have the schema the binary
expects or a newer one: an older dump of the instance database is migrated by the next normal open (a relay
database's by migrate up), and a newer one is used as is by binaries from the bridge release on.
PostgreSQL, sharded relay: separate pg_dumps of control and the shards are not one consistent snapshot. A
control database restored to an earlier point than a shard can route pools and buckets differently from what the
shards hold. A restored older pool shard loses acknowledged records, which clients then report as relay_mismatch /
membership fork; a restored older replay shard forgets consumed nonces. For a consistent set, stop every relay
process, dump every relay database, then start them again, or use point-in-time recovery of every server to the same
moment. Restore the whole set, never control alone, and with every relay process stopped. Prefer the replication
below to restores for durability.
Keys are not in any database; pair database backups with the owner recovery export (Operations).
High availability and failover
merkhain acknowledges a request only after its commit, and reads authorization, replay protection and synchronization data from the database it writes to; there is no read-replica setting. Whether an acknowledged commit survives the loss of a PostgreSQL server therefore depends on how that server is replicated. merkhain does not provision replication, promote standbys or move addresses.
The supported setup, for every relay database (control, pool shards, replay shards) and a PostgreSQL instance database:
- Synchronous standbys.
synchronous_commit = on(the default;remote_applyalso works) with the standbys named insynchronous_standby_names. A commit then waits until a standby has flushed it, so nothing is acknowledged that the standby lacks. Run at least two standbys withANY 1 (s1, s2)orFIRST 1 (s1, s2), so losing one does not stop writes. - Fenced promotion. Before promoting a standby, stop the old primary for good (power it off, kill it, or cut it
off from clients), then promote the standby and repoint the database's address. Running relays reconnect on their
own and requests fail retryably meanwhile; a changed URL in the configuration needs a restart (running processes keep their connection and log
restart_to_apply). - Keep the old primary fenced. Fencing is PostgreSQL configuration plus this procedure: merkhain has no epoch or
fencing token and cannot tell an old primary from the current one. An old primary cannot acknowledge anything only
while no synchronous standby follows it. Before it can run or be reached again, repoint every other standby to the
new primary or stop it: with
ANY 1 (s1, s2)orFIRST 1 (s1, s2), a second standby that still follows (or reconnects to) the revived old primary lets it acknowledge writes, a split brain. Never let clients reach it either: crash recovery makes the commits that were waiting for the standby visible to readers. Rebuild it as a standby of the new primary. - Prefer a virtual IP to a DNS change. Connections to a primary that vanished without closing them (powered off,
cut off) are noticed only when TCP keepalive gives up, after about 2.5 minutes, and until then the relay answers
unavailable. A virtual IP moved to the promoted server ends them at once; a DNS change does not. The database URL cannot shorten this: client keepalive parameters such askeepalives_idleortcp_user_timeoutare not supported and would be sent to the server as settings.
After such a failover every acknowledged record keeps its sequence and envelope, pool and replay-bucket routes and fences keep one owner, and every used request nonce is still refused as a replay. Clients retry the requests that failed during the switch with new nonces.
Asynchronous standbys are not a supported failover target. With asynchronous replication (an empty
synchronous_standby_names, synchronous_commit = local or off, or any lagging replica) the primary acknowledges
commits the standby has not received. Promoting such a standby loses them: records that clients consider uploaded
disappear, other records reuse their sequences (clients that pulled the lost ones never fetch the new ones), nonces of
used requests are forgotten, and lost route changes can leave a pool or bucket with two owners. Use asynchronous
replicas for backups only, and restore from them like from a backup (Backup and restore), not
as a failover. Never let two servers accept writes for the same database.
While no synchronous standby is connected
Writes wait instead of being acknowledged. merkhain never cancels a commit that waits for a standby, because
PostgreSQL would then make it visible on the primary without the standby having it, and PostgreSQL's
statement_timeout does not end that wait either. Meanwhile:
- clients give up after their own deadline and retry later; nothing is acknowledged, and retries are idempotent;
- each waiting request holds a connection and its pool's (or bucket's) lock, so further writes to that pool fail with
busyafter 10 seconds, and requests fail withunavailableonce the database's connections (max_conns) are taken. Reads keep working while connections remain; migrate upand the completion oftransfer local/transfer relaywait the same way;- restarting relays does not help: the waiting transactions stay on the server.
Treat such a primary as degraded, not hung. Alert when pg_stat_replication shows no sync or quorum standby and
when pg_stat_activity shows backends with wait_event = 'SyncRep'. Then either reattach a synchronous standby (the
waiting commits complete durably) or fail over to it with a fenced promotion (they were never acknowledged).
pg_terminate_backend, pg_cancel_backend, a fast shutdown or emptying synchronous_standby_names release them as
committed locally but not replicated, so a failover afterwards loses them: do that only when no standby will be
promoted and the standbys are rebuilt from this primary.
Search on SQLite and PostgreSQL
Recall uses each engine's native full-text search through the same query syntax (words, "phrases", OR,
-exclusions, prefix*), the same scopes, resolved and superseded visibility, fallbacks and result limits. Results
can differ:
| SQLite (FTS5) | PostgreSQL (tsvector) |
|
|---|---|---|
| Stemming | Porter (porter unicode61) |
English Snowball configuration; prefixes are stemmed too (leas* matches like lea*) |
| Diacritics | folded (cafe matches café) |
kept (cafe does not match café) |
| Stop words | none | English stop words are ignored in queries and memories; a query of only stop words matches nothing |
| Prefix terms | foo-bar* is a phrase whose last word is a prefix (foo followed by a word starting with bar) |
the same, but prefixes are stemmed and prefixed stop words (a*, the*) are dropped |
| Ranking | bm25 | ts_rank with title weighted above body; scores are not comparable across engines |
| Snippets | 24 tokens, … at cut ends, adjacent phrase words highlighted as one span |
up to 24 words, no ellipsis, each matched word highlighted separately |
Search page cursors are bound to the engine and refused by the other one; start the search again.
Capacity
The relay benchmark report measures pool concurrency, replay, statistics
freshness and move pauses on one laptop: PostgreSQL 16 and 18 with control, two pool shards and two replay shards on
two servers, three relay processes, and a single-process SQLite relay as the baseline. Its numbers belong to that
host, dataset and load. It is not a capacity statement, and no figure for any number of users, developers or
devices is claimed. Rerun it on your own hardware with packaging/bench/relay.sh (Docker; PG_VERSIONS, REPEAT
and the MERKHAIN_BENCH_* knobs). What it showed, and what follows from the design:
- One pool is one writer at a time. Appends to a pool are serialized by the pool's lock until commit, so a hot pool is bound by commit latency (about 535 appends/s on that host, with the other writers waiting for the lock). Writes to different pools, and nonces of different requests, run concurrently; independent pools do not wait on each other.
- SQLite has one writer per database. Every signed relay request writes a nonce, so a SQLite relay serializes all writes. On one host without a network hop it was faster than PostgreSQL for every measured workload; PostgreSQL adds shards, several processes and online moves, not single-host speed.
- Connections: with the default 16 connections per database and process, no request waited for a connection at
16 concurrent clients. Budget processes × databases ×
max_connsagainstmax_connections. - Pool moves paused the moving pool for about 125–205 ms (pools up to ~7k records or 256 devices; larger ones were not measured). Most of the pause is the final comparison while fenced, which grows with the pool's membership and device rows and with the records appended since the last verification. Other pools were unaffected.
- Replay-bucket moves pause the moving bucket for one scan of the source shard's whole nonce table (about 9 µs per row on that host: 0.3–0.5 s for ~35k rows, 2.1–2.5 s for ~240k). A replay shard holds about 600 rows per sustained request/s it serves (nonces live about 10 minutes), so move buckets off a busy shard when it is quiet.
- Nonce pruning removes up to 10,000 rows per replay database and process each minute; while a round leaves
expired rows behind, the next round follows after one second instead, so pruning keeps up with accepted traffic
rather than capping it at about 170 signed requests/s per replay shard. Each device may send a burst of 600 signed
requests, then 10 per second, per relay process (429
busybeyond that), which bounds the nonces one device adds. - Published statistics lag by up to a minute plus the collection round; a round reads the pool shards one after the other (about 100 ms for two shards and ~66 pools under load), and takes up to about the number of shards times the shard timeout when shards are slow.
Not measured: network latency between relays and databases, replication, TLS, long runs, large datasets, more than two shards per role, and PostgreSQL instance databases (capture and recall).
Recovery from failed operations
| Situation | What to do |
|---|---|
serve remote or a relay command refuses with "requires a migration" |
back up, merkhain migrate up --relay (or the named --database) with the new binary |
db_schema_not_current from status --direct |
pending local migrations: the next normal open (any command, hook or serve local) applies them; a dirty or invalid state is named in the report |
migrate status shows dirty |
a migration outside merkhain's transaction failed: repair the schema by hand, then migrate force … VERSION; otherwise restore the backup |
schema too new (a binary from before the bridge release) |
use a binary from the bridge release or later, or restore the backup taken before the upgrade |
migrate down or force refuses with "newer than this binary" |
the role is ahead: run them with the newer binary, or keep the schema and run the older binary against it |
transfer local or transfer relay exits 75 |
a writer or another transfer is active: stop it, run the same command again |
transfer relay refuses the source for unfinished work |
finish or cancel the move (move status), or start serve remote on the SQLite relay once so an interrupted pool creation completes; stop it again and retry |
transfer relay refuses the target (topology, not migrated, holds data, another transfer) |
the hint names the cause: migrate up --relay, register the pool and replay shards, or drop the target databases and prepare them again |
transfer local fails verification, or the target holds rows of another source |
stop every process using the target, drop the target schema (or database), transfer again |
A command refuses the target as "the target of an unfinished offline transfer" ("transfer": "copying") |
finish it with transfer local, or drop it and point the configuration back at SQLite |
move pool / move bucket stopped or failed |
merkhain move status, then move resume ID (from any host); before the source is fenced, move cancel ID also works |
Requests for one pool or bucket answer 503 unavailable for a long time |
an unfinished move fenced it: move status, move resume ID |
shard register refuses a shard |
the hint names the cause: not migrated (migrate up --database NAME), unreachable, or identity mismatch (the URL reaches another database) |
shard status shows unstamped |
the shard was migrated down below 460: restore it (see the warning under Down and force) |
Relay writes hang or fail with busy/unavailable; backends wait on SyncRep |
no synchronous standby is connected: reattach one, or fail over with a fenced promotion (While no synchronous standby is connected); do not restart relays |
| A shard's server is lost | its pools (or buckets) fail with unavailable; promote its synchronous standby, or restore the whole relay set |
Limitations
- SQLite and PostgreSQL 16 and 18 only. PostgreSQL runs with the
englishtext search configuration; diacritic folding (unaccent) is not used. - The recall comparison keeps its queue and fuzzy vocabulary in the SQLite evidence index on both dialects
(
recall-benchmarks/v1/index.dbin the data directory), so its spelling candidates come from spellfix1 on both. Full-text search still runs on the instance database, with its engine's stemming and ranking, so the fuzzy results can differ between dialects. - A SQLite relay is one database and one process; it has no shards and its pools cannot move.
transfer relaymoves it to PostgreSQL offline; there is no online path and no transfer back to SQLite. - Pools stored on a PostgreSQL control database that hosts the pool role have no route and cannot move.
- Adding pool or replay shards to a configuration takes that role away from the database that hosted it. A control database that served every replay bucket, then gets replay shards, abandons the nonces it recorded for up to one replay window: configure replay shards before serving traffic, or keep the relays stopped for one window (about 10 minutes, twice the accepted 5-minute clock skew) before switching.
- Shards cannot be unregistered, and a registered shard's roles cannot change.
- The replay history check at the first bucket placement only sees the replay databases configured on the process that registers, and judges "live" by that host's clock.
- Duplicate databases are detected by identical URLs and by identity stamps on registration, not by comparing servers under different spellings before registration.
- Expired enrollment locators and published pool provisions are never cleaned up.
db_sqlite_data_left_behindonly checks the default<data_dir>/merkhain.db.- On sharded relays the console's team list comes from published statistics (up to a minute old, no update times) and relay-wide enrollment counts are unavailable.
- merkhain does not detect an asynchronous replication setup, has no fencing token against a revived old primary, and does not bound a commit waiting for a missing synchronous standby. The failover tests promote one cluster with a single standby; the two-standby split brain that the fencing procedure prevents is not tested. A PostgreSQL instance database's failover is covered by the same adapter behavior, not by its own test.
- For contributors:
go test -race ./internal/relayagainst PostgreSQL takes more than 10 minutes; CI gives it a timeout of 30 minutes. Keep the repository's test timeouts when running it locally.