Disaster Recovery
A runbook-style summary of VeltrixDB's disaster recovery procedures, covering pod crash loops, data corruption, write outages, GC death-spirals, backup/restore, encryption key rotation, failed disks, metadata repair, WAL-format rollback and a lost raft quorum. Assumes kubectl admin access with pods labeled app.kubernetes.io/name=veltrixdb in the veltrixdb namespace (the kubectl veltrix plugin's defaults).
sh, curl, rm or nc), so kubectl exec POD -- curl … does not work. Reach the admin port with kubectl port-forward from your workstation, and run file commands from an ephemeral debug container that shares the server's process namespace (kubectl debug --target=veltrixdb; the server's filesystem is then under /proc/1/root) or a node debug pod (host filesystem under /host). The kubectl veltrix plugin port-forwards for you; on v1.13 and earlier it sends no admin token, so it only works on servers started without --admin-token. Fixed in v1.14 it takes --admin-token / VELTRIX_ADMIN_TOKEN (and --admin-url to skip the port-forward).
Severity classes
| Class | Definition | Engage on-call |
|---|---|---|
| SEV-0 | Cluster-wide write outage or confirmed data loss | < 5 min |
| SEV-1 | One pod down, replicas serving | < 15 min |
| SEV-2 | Read latency > 5x baseline | < 1 hour |
| SEV-3 | Single disk slow, scrubber alerting, no user impact | < 4 hours |
1. Pod crash loop (SEV-1)
Start by pulling logs and pod status:
kubectl logs -n veltrixdb POD --previous | tail -100
kubectl describe pod -n veltrixdb POD
| Log line | Cause | Fix |
|---|---|---|
[wal] replay of …/wal.log stopped at byte N of M after K records | Torn final write on crash (small M − N), or WAL damage (large). Every record is CRC32C-checked and replay stops at the first bad one. | Restart the pod once. If it recurs or M − N is large, treat it as corruption: wipe the node's data and let replication refill it (section 2), or restore from backup (section 5). |
panic: native index: insert failed (out of memory?) New in v1.6 | vm.max_map_count too low for the off-heap native index (cgo builds, including the Docker image) | Apply scripts/sysctl.conf (vm.max_map_count = 262144) on the node; or set VELTRIXDB_INDEX=map to use the Go map index |
vlog bad magic at offset O / vlog CRC32C mismatch at offset O | Silent disk corruption | Drain the node, replace the disk, re-join |
[search] rebuilt N vectors and M text documents in T New in v1.11 | Normal after every start: search indexes are rebuilt from the persisted keys, and searches are refused until this line (INFO shows search_ready=1) | Nothing — size restart windows by T for large vector namespaces |
client error search indexes are still rebuilding after restart (loaded x of y) | A search reached a node (or a fan-out peer) before its rebuild finished | Retry; or run with --search-allow-partial if incomplete results are acceptable |
[transfer] WARNING: listener … is unauthenticated | No --cluster-secret-file and no cluster mTLS | Mount the same secret (≥ 16 bytes) on every node (--cluster-secret-file or VELTRIXDB_CLUSTER_SECRET) and restart — see Security |
encryption: enabled but no key in VELTRIXDB_ENCRYPTION_KEY and no EncryptionKeyPath set | --encrypt-at-rest with no key (env unset, no --encryption-key-path) | kubectl create secret generic veltrixdb-enc --from-literal=key=BASE64_32B and reference it from the pod env |
2. Data corruption (SEV-0)
- Confirm corruption via metrics:
kubectl port-forward -n veltrixdb POD 2112:2112 & curl -s localhost:2112/metrics | grep -E 'scrub_corruption_total|vlog_gc_read_errors_total' - Find the bad disk:
kubectl logs -n veltrixdb POD | grep -E '\[scrub\] disk=.*MISMATCH'(lines reportMAGIC MISMATCHorCRC MISMATCHwith the disk and offset) - Quarantine the pod:
kubectl label pod -n veltrixdb POD veltrixdb.io/quarantine=true --overwrite - Wipe the node's data and restart — raft or replicated mode only, where the other nodes refill it (Raft log catch-up /
InstallSnapshot). In standalone mode restore from backup instead (section 5):
In replicated mode only writes the primaries still hold un-acked are re-sent; there is no full-state resync, so verify the refilled node.kubectl debug -n veltrixdb POD -it --image=busybox:1.36 --target=veltrixdb -- \ sh -c 'rm -f /proc/1/root/mnt/nvme*/vlog_active.dat /proc/1/root/mnt/nvme*/wal.log*' kubectl delete pod -n veltrixdb POD - After replay completes, remove the quarantine label:
kubectl label pod -n veltrixdb POD veltrixdb.io/quarantine- --overwrite - Root-cause the disk from a node debug pod:
kubectl debug node/NODE -it --image=ubuntu:24.04 -- chroot /host smartctl -a /dev/nvme0n1
3. Write outage (SEV-0)
kubectl get pods -n veltrixdb -o wide
kubectl debug -n veltrixdb POD -it --image=busybox:1.36 --target=veltrixdb -- \
sh -c 'df -h /proc/1/root/mnt/nvme*'
kubectl port-forward -n veltrixdb POD 2112:2112 &
curl -s localhost:2112/metrics | grep -E 'storage_wal_flushes_total|vlog_garbage_ratio'
| Cause | Fix |
|---|---|
| Disk 100% full and GC paused | kubectl veltrix checkpoint; check kubectl veltrix gc-status. On v1.16 and earlier a runtime checkpoint leaves later writes not crash-safe on standalone and replicated nodes until a clean restart — restart the pod cleanly afterwards (section 5) |
| GC can't keep up | kubectl veltrix quota-set NS 1000 5000000 — throttle writes |
| Network partition | kubectl debug -n veltrixdb POD -it --image=busybox:1.36 -- nc -zv OTHER_POD 9000 — check CNI/NetworkPolicies |
4. GC death-spiral / garbage ratio > 65% (SEV-2)
- Throttle writes immediately:
kubectl veltrix quota-set tenant_42 1000 5000000, then watch GC work through it withkubectl veltrix gc-status(ratio per disk, runs, emergency state). - Do not look for a runtime GC-budget knob: the GC bandwidth tiers (60 MB/s throttled, 200 MB/s critical at ≥ 50% garbage, uncapped emergency mode at ≥ 65%) are compile-time constants in
storage/defrag.go, and there is no environment variable that overrides them.-gc-thresholdonly sets when normal GC starts. - Long-term: add disks (scale
volumeClaimTemplates, re-roll the StatefulSet).
5. Backup and restore
# Full backup of a RUNNING server: through the admin API, written on the
# server's own filesystem (dest_dir is a path inside the pod)
kubectl port-forward -n veltrixdb POD 2112:2112 &
curl -s -X POST localhost:2112/admin/backup \
-d '{"type":"full","dest_dir":"/mnt/nvme0/backup/'$(date +%F)'"}'
# incremental: {"type":"incremental","dest_dir":"...","base_dir":"<previous backup dir>"}
# (same as a full backup: veltrix --addr 127.0.0.1:2112 backup DEST_DIR)
# Offline tool: ONLY while the server is STOPPED (see warning below)
veltrixdb-backup full --data-dirs=/mnt/nvme0,...,/mnt/nvme7 --dest=/backup/$(date +%F)
# Upload to S3 (prints the cloud path: <prefix>/<backup_id>/, default prefix veltrixdb-backups)
veltrixdb-backup upload --src=/backup/$(date +%F) \
--provider=s3 --bucket=my-bucket --region=us-east-1
# Download and restore (stop the engine first)
veltrixdb-backup download --provider=s3 --bucket=my-bucket --region=us-east-1 \
--cloud-path=veltrixdb-backups/full-1748001600000000000/ --dest=/tmp/restore
veltrixdb-backup restore --chain=/tmp/restore --data-dirs=/data-new
POST /admin/backup, veltrix backup) or POST /admin/checkpoint (veltrix checkpoint, kubectl veltrix checkpoint) leaves the WAL flusher writing to an unlinked copy of wal.log. On standalone and replicated nodes every write acknowledged afterwards is lost on a crash, power loss, OOM kill or SIGKILL until the node’s next clean restart, which rewrites wal.log from the in-memory index. In replicated mode the crashed node also loses those writes as a catch-up source for its replicas; raft mode recovers them from the raft log. On v1.16 and earlier, after each online backup or checkpoint, restart the node cleanly (SIGTERM and wait for exit — e.g. kubectl delete pod within the grace period, not --force), one node at a time in a cluster. Fixed on main after v1.16 — ships in the next release the checkpoint swaps the flusher onto the new file, so writes made after a backup survive a crash. See Backup & Restore.
veltrixdb-backup on a live server truncates its files. It opens the data dirs with its own engine; on v1.13 and earlier, pointed at a running server's directories it produced an empty backup and truncated the live wal.log and vlog_active.dat in testing. Use POST /admin/backup for running servers. Fixed in v1.14 a data-dir lock makes it refuse a live server’s dirs (ErrDataDirLocked) without touching them; veltrix-repair and a second server are refused the same way. The tool is not in the server image (the Dockerfile builds only cmd/server). With --raw-vlogs, backups contain no VLog data.
Kubernetes volume snapshots are an operator-side alternative if your CSI driver supports them: create a VolumeSnapshot object per PVC (there is no kubectl create volumesnapshot subcommand, and the main repository ships no snapshot manifests). Run POST /admin/checkpoint first so wal.log is a compacted checkpoint.
Backups and PITR restores write a binary WAL; restoring into a build that predates the binary WAL (v1.5 and earlier) needs the WAL rollback procedure first. For the full backup/incremental/PITR model, see Backup & Restore.
6. Encryption key rotation
kubectl veltrix migrate (POST /admin/migrate) only rewrites records whose schema version is old; it does not re-encrypt.
The only way to change the key is to move the data through a client:
- Start a fresh node (empty data dirs) with
--encrypt-at-restand the new key. - Read every key from a node still running with the old key and write it into the new node (for example
veltrix-migrateexport/import over the binary protocol). - Cut clients over to the new node, then retire the old one and its key.
On v1.13 and earlier, values written by atomic ops (CAS/INCR/DECR/SETNX) are stored unencrypted regardless of the key (known gap). Fixed in v1.14 they are encrypted like PUT; values an older build wrote stay plaintext until their next update.
7. Failed disk — auto-degraded node (SEV-1)
VeltrixDB trips a per-disk breaker after 5 consecutive I/O errors on a disk's WAL/VLog. The disk is marked FAILED: writes routed to it fail fast with ErrDiskFailed, GC skips it, and the node reports degraded. Reads still try the device and are served from cache when possible. Detection:
# Readiness probe fails while the node keeps serving reads
kubectl port-forward -n veltrixdb POD 2112:2112 &
curl -s -w '%{http_code}\n' localhost:2112/readyz
# degraded: disks [2] failed
# 503
# Which disks tripped
kubectl logs -n veltrixdb POD | grep '\[disk\]' # "disk=2 marked FAILED after 5 consecutive ... errors"
INFO (text protocol) also appends FAILED_DISKS=[2] for a degraded node, and the engine's internal DiskFailures counter records breaker trips (not exported to Prometheus).
Recovery sequence:
- Drain: the failing
/readyzalready stops new traffic; let in-flight work complete, then take the node out of rotation (cordon the K8s node or scale traffic away). - Replace: swap the failed device (see section 1's
bad magicrow and section 2 for corruption handling on the same disk). - Restart: restart the pod/process — the breaker state is in-memory, so a restart with a healthy disk comes back clean. In raft mode the leader refills the replaced disk's data through log catch-up or
InstallSnapshot; in standalone mode restore from backup; in replicated mode there is no full-state resync, so restore or re-copy the data.
8. Value-transform metadata damage from a pre-fix build (SEV-1) Fixed in v1.3
Affects nodes that ran a build predating the value-transform WAL fix with compression (zstd is the default) or -encrypt-at-rest, and that restarted since writing. After a clean restart, reads succeed but silently return the raw compressed or encrypted blob (a 1040-byte value comes back as ~51 bytes starting 02 28 b5 2f fd, the zstd magic); after a crash, reads fail with vlog CRC32C mismatch. Upgrading alone does not fix it; the VLog bytes are intact, so it is repaired in place:
# 1. Assess, read-only (whole fleet; exits non-zero if any node needs work)
./scripts/repair-scan-fleet.sh --hosts hosts.txt --data-dirs /mnt/nvme0,/mnt/nvme1
# 2. Repair each node with the server STOPPED
kubectl scale statefulset veltrixdb --replicas=0
veltrix-repair --data-dirs /mnt/nvme0,/mnt/nvme1 --repair
# encrypted: add --encrypt-at-rest (key from VELTRIXDB_ENCRYPTION_KEY or --encryption-key-path)
# data not written with zstd: --compression=none|flate
Each wal.log is copied to wal.log.prerepair.<timestamp> first. The repair is exact: it accepts only the interpretation of each blob that reproduces the stored plaintext CRC, and is idempotent. Verify by reading back values larger than 256 bytes and re-running the scan. Encrypted deployments need the key (VELTRIXDB_ENCRYPTION_KEY plus --encrypt-at-rest), or those records report as unresolved. See veltrix-repair.
9. WAL format: upgrade and rollback New in v1.6
WAL records are binary by default. Replay reads binary and legacy text records in any mix, so upgrading needs no step: the new build replays the existing text WAL and appends binary records after it. A build that predates the binary WAL cannot read it. To roll back:
- On the current build, restart once with
--wal-format=text(add it to the StatefulSet args and re-roll). The server logs[wal] WARNING: --wal-format=text …. - Stop it cleanly (SIGTERM, not SIGKILL):
kubectl scale statefulset/veltrixdb --replicas=0 -n veltrixdb. The shutdown checkpoint writes a compactedwal.log.ckptand renames it overwal.log, now in text. - Deploy the older build.
If the node crashed instead of stopping cleanly, repeat steps 1–2. While running with --wal-format=text, keys containing | or a newline are not crash-safe: a crash can drop every acknowledged write after such a key.
Fixed in v1.14 the WAL record of a key with a TTL carries its expiry (binary record version 2, or an 11th text field), which no older build can read — it stops replay there and drops every later record. To roll back to v1.13 or older, use --wal-format=text-legacy in step 1 instead of text (which keeps the TTL field and is not a rollback mode). TTL’d keys come back without an expiry on the older build. On v1.13 and earlier the steps above are unchanged. Rolling back to v1.14 or later, v1.16 included, needs no WAL step: those builds read binary version-1 and version-2 records and keep TTLs, after a clean stop or a crash. Do not run text-legacy for them — it only makes every TTL’d key immortal.
Fixed in v1.15 Raft state moves from raft_state.gob to raft_log.dat + raft_meta.dat; the old file is migrated on first start, and v1.14 and earlier cannot read the new files. To roll a raft node back, stop it, delete its whole --data directory, start the older build with the same --node-id / --peers and let it re-sync from the leader, one node at a time. Do not start an older binary on a migrated directory. See Raft log persistence.
Fixed on main after v1.16 — ships in the next release the raft snapshot moves from raft_snapshot.gob to raft_snapshot.dat (migrated on start). Rolling a raft node of that release back to v1.15 or v1.16: a node with no raft_snapshot.dat starts as it is; a node with one must be wiped and re-synced as above (v1.16 refuses it with corrupt raft log … does not follow the snapshot (index 0)). Roll back followers first and the leader last. For a rolling upgrade, see Deployment Modes.
10. Raft quorum lost for good New on main after v1.16 — ships in the next release
v1.16 and earlier have no --force-new-cluster flag. On main, if a quorum is permanently gone (e.g. two of three voters destroyed): stop every survivor and start one of them with --force-new-cluster. It appends a configuration holding only itself in a new term, elects itself and commits its whole log — including entries that were never committed. Remove the flag once it is up, then wipe every other former member and add it back with --join (see the membership runbook). Never force two nodes, and never force while the old quorum may still be running. In replicated mode --force-new-cluster moves cluster_membership.json aside and starts from --peers; do it on every node with the same --peers.
Contact
- On-call:
@veltrixdb-oncallin PagerDuty - Slack:
#veltrixdb-incidents - Runbook owner: SRE team — review quarterly