Cluster Backup & Restore — Design
Date: 2026-07-05
Status: Approved
Scope: internal/cluster/**, internal/cluster/proto, test/e2e/, plus two REST endpoints in internal/rest (explicitly approved boundary crossing).
Context
ZenBPM runs one embedded rqlite (v10.2.0) raft group per partition. Facts this design relies on, verified against the code:
- Each partition store exposes rqlite's native backup/restore primitives:
store.Backup(ctx, req, dst)(vacuumed, point-in-time, optionally gzipped binary SQLite copy, leader-enforced) andstore.Load(ctx, lr)(loads a full SQLite image through the raft log as a single gzip-compressed entry, replicated to followers). These are the same code paths behind rqlite'sGET /db/backupandPOST /db/load. (Amended 2026-09-09:store.Loadis no longer used by restore — see decision 4.) - Per-partition auto-backup/auto-restore to S3-compatible storage is already wired
(
AutoBackupFile/AutoRestoreFile,internal/cluster/partition/partition.go). It remains untouched as an optional extra. - gRPC stubs
ClusterBackup,ClusterRestore,PartitionBackup,PartitionRestoreexist inzen_cluster.protoas unary RPCs with empty messages, returningUnimplemented(internal/cluster/server/server.go:177-200). - Message subscriptions are authoritative on the process instance's partition
(
message_subscriptiontable; subscription key is a zenflake key). The hash partition (GetPartitionIdForMessageSubscriptionPointer:hash % partitionCount + 1) holds only a derived routing row inmessage_subscription_pointer(name, correlation_key [PK], state, created_at, message_subscription_key). Every pointer column is derivable from the authoritative subscription row. - Process/decision definitions are replicated to all partitions and are immutable per key.
- Routing depends on
hash % len(partitions): a backup set is only restorable into a cluster with the same partition count.
Goals
- Whole-cluster backup and restore of all partition databases.
- Scenarios: disaster recovery, point-in-time rollback, environment cloning.
- No external infrastructure required: backup streams to the operator's HTTP client; restore streams back. The backup is "done" only when the bytes are off-cluster.
- Reuse rqlite machinery maximally; no custom snapshot formats.
Non-goals
- Single-partition repair (restore is always all partitions from one backup set).
- Incremental backups.
- Built-in scheduling (operators use cron + curl; rqlite auto-backup remains for S3/MinIO environments).
- Backing up cluster topology metadata (the HashiCorp raft cluster store). Topology is environment-specific; only the partition count matters and lives in the manifest.
Decisions and rationale
- Cross-partition consistency: reconcile on restore. Partition snapshots are taken concurrently (skew: seconds), with no pause and no checkpoint protocol. The subscription-pointer table is treated as a disposable derived index: it is included in the backup (rqlite snapshots whole files; excluding a table would add complexity for no benefit) but wiped and rebuilt during restore. Rejected alternatives: quiesce-during-backup (write unavailability on every backup), Zeebe-style checkpoint markers (touches every cross-partition code path).
- Trigger/download: synchronous streaming bundle (rqlite/etcd/Consul shape). Rejected alternatives: push-to-object-store as primary (S3 must not be required), two-phase stage-and-download (backup registry + staged-file GC + the staged copy dies with the node; unneeded for SQLite-sized partition files).
- Restore targets a live cluster only with
force=true; without it, restore is refused unless the cluster is empty (no deployed definitions, no instances). - Restore loads partitions sequentially to bound coordinator memory.
(Amended 2026-09-09.) The original design handed each image to
store.Load, which holds the whole decompressed database in memory as a single raft entry and copies it several more times while marshalling, replicating and applying it: a 1 GiB compressed bundle exhausted 32 GiB of RAM.SetRestorePathreads the file into memory and goes through the same load, so it was never an escape hatch. Restore now decompresses the image to disk on the partition leader and copies it into the partition as bounded statement batches through the raft log (backup.CopyDatabase): reset the target schema, recreate the image's tables from their DDL, copy rows parents-first with every value spelled as a SQL literal by SQLite itself, then recreate indexes, views and triggers. A batch holds at most 4 MiB of SQL unless a single row renders larger (a row travels in a batch of its own then), and rows are capped bymaxPartitionRowBytes(32 MiB, checked before the partition is touched): the largest row, not the image, bounds the memory per batch. The decompressed image lives in the configured spool directory (default under the node's data directory, never the OS temp dir, which may be tmpfs); the size of a restorable partition is bounded by disk space andmaxPartitionDatabaseBytes. Tables that reference themselves or form a foreign-key cycle cannot be copied as separate transactions and are refused up front; ZenBPM's schema has none. - The proto's
LoadChunkRequestis legacy — v10 exposes no chunked-load store method. Do not design against it.
Operator API
GET /system/v1/cluster/backup→ streamszenbpm-backup-<timestamp>.tar. Callable on any node; that node coordinates.POST /system/v1/cluster/restore[?force=true]← streams the same bundle back.- (Amended 2026-07-06: endpoints moved from
/v1/cluster/...to/system/v1/cluster/...— the/v1prefix is reserved for the business API documented inopenapi/api.yaml; the operational plane lives under/system, with versioning as a sub-path for contract-carrying APIs.) - Both endpoints live in
internal/restand delegate to the cluster node (ZenNode) — thin HTTP shims only. - gRPC equivalents on
ZenServicefor gRPC-native operators:ClusterBackup(ClusterBackupRequest) returns (stream BackupChunk)ClusterRestore(stream RestoreChunk) returns (ClusterRestoreResponse)— request metadata (force flag, format version) in the first chunk; response carries the restore report.
- Internal fan-out RPCs:
PartitionBackup(PartitionBackupRequest) returns (stream BackupChunk)— coordinator → partition leader; request carries partition id, vacuum/compress.PartitionRestore(stream RestoreChunk) returns (PartitionRestoreResponse)— coordinator → partition leader.
- Proto changes require
make generate; generated files are never hand-edited.
Backup flow
-
Coordinator verifies every partition has a leader; otherwise fail fast.
-
Fan out concurrently to all partition leaders: each runs
store.Backup(BINARY, vacuum=true, leader=true)into the gRPC chunk stream. The leader computes sha256 over the bytes it sends and emits it in the final chunk, so the digest is anchored at the source, not at the coordinator. (Snapshot consistency on the leader is rqlite's:VACUUM INTOunder a read transaction — writes continuing during backup cannot appear partially.) -
Each partition's gzipped chunk stream is spooled to a coordinator temp file while being fed through a sha256 hasher (tar headers need the entry size up front, so pure pass-through streaming into a tar is not possible). When the stream ends, the coordinator verifies its computed digest against the leader-sent one — a mismatch aborts the backup loudly instead of poisoning the bundle. As each verified spool completes, its tar header + bytes are written to the HTTP response and the spool file is deleted. Snapshots still start concurrently, so point-in-time skew between partitions stays seconds even when transfers take minutes. Worst-case coordinator temp disk is the bundle size — the same requirement restore already imposes. The archive itself is not gzipped: each partition file already is (rqlite streams gzip when
compress=true), so outer compression would be wasted work. -
Bundle layout:
zenbpm-backup-<ts>.tar
├── partition-1.db.gz
├── partition-2.db.gz
├── ...
└── manifest.json (last entry — carries checksums computed while streaming) -
manifest.jsonfields: manifest format version, zenbpm version, partition count, and per partition: schema/migration version, snapshot timestamp (unix millis), stored size, sha256 of the stored (gzipped) partition file bytes — checksums exist to detect corruption of the bundle, so they cover the bytes as stored. -
A backup whose stream fails mid-way is aborted; the client sees a truncated tar with no manifest, which restore rejects. Retry is cheap.
Restore flow
- Coordinator spools the uploaded tar to its temp directory (deleted afterwards),
then validates before any destructive action: manifest present and parseable,
partition count matches the cluster, sha256 checksums, backup schema version ≤
binary's schema version, every partition file a valid SQLite image per
IsValidSQLiteData(checked on the gunzipped stream from the spooled file). - Empty-cluster check: refused with a clear error if the cluster holds any
deployed definitions or instances, unless
force=true. - Maintenance gate: a raft-replicated cluster-state flag moves all partitions
to a
Restoringstate. Client-facing operations return Unavailable; job manager, timers, and engine processing pause. Reuses the same gating mechanism as the schema-gatedINITIALIZEDstate. - Per partition, sequentially: send the image to the partition leader, which decompresses it to disk and copies it into the partition as bounded statement batches; raft replicates every batch to followers.
- Re-run schema migrations on every partition (the loaded file may carry an older schema than the running binary).
- Run reconciliation (below).
- Un-gate partitions; return the restore report in the response.
Failure after the first Load leaves the cluster in Restoring state with a
clear error. The operator retries the restore; the operation is idempotent
(every step re-loads/rebuilds from the bundle). There is no automatic rollback
of a failed restore.
Accepted, documented quirk: workers holding pre-restore job keys receive NotFound on complete/fail — inherent to any rollback.
Reconciliation (restore-time)
Runs after all partitions are loaded and migrated, before un-gating:
- Pointer rebuild. Wipe every partition's
message_subscription_pointertable. Scan every partition'smessage_subscriptionrows in ACTIVE state (covers definition-level subscriptions — empty correlation key — as well). For each row compute the pointer's home partition viaGetPartitionIdForMessageSubscriptionPointer(name, correlationKey), then per home partition apply one raft-replicated transaction:DELETE FROM message_subscription_pointer+ batch insert of recomputed rows (pointercreated_attakes the subscription'screated_at).- Duplicate policy: if two ACTIVE subscriptions share
(name, correlationKey)(possible from snapshot skew), the newestcreated_atwins the pointer; the loser is listed in the restore report as a potentially-waiting instance (visible and cancellable, not silently lost). This re-establishes the same one-active-per-key invariant the runtime enforces inpartition_persistence.goSaveMessageSubscriptionPointer. - This repairs both skew anomalies: dangling pointers vanish with the wipe; missing pointers are recreated from the surviving subscription.
- Duplicate policy: if two ACTIVE subscriptions share
- Definition sync. Diff process/decision definition keys across partitions; copy missing definitions to partitions lacking them (immutable per key, so copying is safe).
- Restore report (returned to the operator and logged): per-partition load timings, pointers rebuilt count, duplicate conflicts with losing subscription keys, definitions synced.
Reads during reconciliation go through partition queries (never the engine); writes go through each partition's raft log.
Testing (TDD order)
- Unit (
internal/cluster/...): manifest serialization round-trip; checksum verification; partition-count mismatch rejection; pointer-rebuild logic including duplicate policy (table-driven); empty-cluster detection. - Store-level: backup → load round-trip on a test partition
(
newMustTestStore()pattern). - E2E (
test/e2e/): multi-node cluster → deploy definitions + start instances with cross-partition message subscriptions → take backup → mutate state → restore withforce→ assert: instances resume, publishing a message correlates correctly through rebuilt pointers, dangling/missing pointer scenarios are repaired, restore withoutforceon a non-empty cluster is refused, restore with mismatched partition count is refused.
Implementation phases
- Backup path: proto streaming RPCs +
make generate;PartitionBackuphandler wrappingstore.Backup; coordinator fan-out + tar/manifest; REST download endpoint. - Restore path: spool + validation; maintenance gate (
Restoringstate); sequentialstore.Loadper partition; migration re-run; REST upload endpoint withforce. - Reconciliation: pointer rebuild + definition sync + restore report.
- E2E coverage and operator documentation (
docs/), including the auto-backup (S3) interplay: independent per-partition uploads have interval- sized skew; the same reconciliation applies when restoring from them.
Similar systems considered
- rqlite: sync
GET /db/backupstream + auto-backup to S3;/db/loadfor running-cluster restore. Adopted as the per-partition primitive. - Zeebe (closest analog): async backup ID pushed to shared object storage, checkpoint markers for causal consistency, same-partition-count restore requirement. Rejected S3 dependency and checkpoint complexity; kept the same-partition-count rule and the manifest/report ideas.
- etcd / Consul: sync snapshot streamed to client; scheduling delegated to an external agent. Adopted this trigger/download shape.
- CockroachDB: nodes push to object storage with MVCC
AS OF SYSTEM TIMEconsistency — requires global timestamps we don't have.