The runbooks had drifted to the retired m8/m11p5 design while all m12 production reality (topology, perf, fixes, DR) sat only in a profiling doc no operator opens. This promotes that reality into the runbooks and fixes the contradictions. Contradictions fixed: - runbooks/cluster.md: the "NEITHER IS QUORUM-ACKED HA YET" status banner was FALSE (quorum-ack + automatic election have been live since m11p3/p4). Rewritten to state the deployed reality (single-StatefulSet full-placement RF3, rc7). - README.md: the cluster section called the HA cluster a "built-in simulated cluster / multi-region fabric" demo and showed promote-by-region as failover. Rewritten — real quorum HA, automatic failover, /cluster/promote is a maintenance verb. Kept the honest caveats (experimental gate, global-signals-only). Reality promoted into the runbooks: - Live topology (single STS, ns tidaldb-cluster, 3 voters, full-placement RF3, gRPC 9601/9602/9603, HTTPS+mTLS :9500), the five shipped fixes, and the real build+digest-pin procedure (cross-compile -> trixie -> amd64 PLATFORM manifest, not the index/attestation digest) in cluster.md + kubernetes.md. - Stale constants: soak ramp 3900 -> 200 rps; cluster grace 60 -> 600s; the pre-m12 4.5k/s signal-write perf table annotated + the 1536-D read reality added. - ops/capacity-planning.md: new "Ref-A 3-node fleet — measured capacity" section (read p99/ceiling, ~250 rps write knee, 1M needs >16GiB nodes, pod resources). - ops/recovery.md: new cluster-recovery routing section + scoped the quiesce-and- copy note to standalone (the cluster uses tidalctl + the DR runbook). New docs: - runbooks/disaster-recovery.md: the proven S3/R2 backup -> restore -> byte-verify -> query-proof procedure, full-cluster rebuild, PITR posture (previously undocumented despite being proven against real S3). - runbooks/on-call.md: incident response — symptom -> golden signal -> runbook, severity, escalation, and the open alert-wiring step. - runbooks/README.md: the runbook index + current production facts. Open follow-up (infra, not docs): ops/prometheus-alerts.yaml is accurate but design-reference; promoting it to a live PrometheusRule is the one unwired step.
184 lines
8.6 KiB
Markdown
184 lines
8.6 KiB
Markdown
# tidalDB Disaster Recovery Runbook
|
|
|
|
Object-store backup, restore, byte-verification, and the boot-on-restore query-proof
|
|
for the **cluster** deployment (ns `tidaldb-cluster`). For single-node/standalone
|
|
recovery (corrupt keyspace, stale lock, quiesce-and-copy) see
|
|
[`../ops/recovery.md`](../ops/recovery.md). For the live operational API see
|
|
[`cluster.md`](cluster.md) and [`kubernetes.md`](kubernetes.md).
|
|
|
|
> **Status (2026-06-19): PROVEN against real S3.** The full
|
|
> export → restore → byte-verify → **query-proof** round-trip is green against a
|
|
> real S3 server (self-hosted MinIO on the fleet). Production Cloudflare R2 is the
|
|
> *same* `tidalctl` code path — only the endpoint URL and the token differ (see
|
|
> [R2](#production-r2)).
|
|
|
|
---
|
|
|
|
## What DR protects, and the recovery posture
|
|
|
|
- **Unit of backup = a per-shard data dir at rest.** The cluster is full-placement
|
|
RF3 (every pod hosts all 3 shard groups under `/data/db/shard-0000N`). A backup
|
|
captures one shard's data dir (fjall keyspaces + `wal/` + `vector/` HNSW graphs +
|
|
the `checkpoint.meta` marker). Back up each of the 3 shards.
|
|
- **Integrity = BLAKE3 manifest + per-shard `checkpoint_seq`.** `tidalctl backup`
|
|
writes `BACKUP_MANIFEST.json` (per-file BLAKE3 + `checkpoint_seq`) and uploads it
|
|
**last** as the atomicity marker. `tidalctl restore` verifies every file's BLAKE3
|
|
before writing a byte and refuses a non-empty target or a path-traversal manifest.
|
|
- **RPO/RTO posture (honest):** backup operates on a data dir **at rest** (a
|
|
drained/stopped node or a streamed point-in-time copy), so the snapshot is
|
|
trivially consistent. There is **no arbitrary point-in-time recovery yet** — the
|
|
WAL-archive primitive exists but `tidalctl replay --until <seq>` is not shipped
|
|
(see [PITR](#pitr-posture)). Restore is offline (boot a server on the restored
|
|
dir); RTO is dominated by HNSW load (~5 min/100k single-core, or instant if the
|
|
persisted graph restored cleanly).
|
|
|
|
---
|
|
|
|
## DR infrastructure (live)
|
|
|
|
Self-hosted MinIO is the real S3 backend (it is a real S3 server, **not** a mock).
|
|
Manifests live in the **orchard9-k3sf** repo:
|
|
|
|
| Artifact | Path (orchard9-k3sf) | Purpose |
|
|
|---|---|---|
|
|
| MinIO | `deployments/k8s/base/tidaldb-dr/minio.yaml` | S3 backend, ns `tidaldb-dr`, ClusterIP `minio.tidaldb-dr.svc:9000`, 5Gi PVC |
|
|
| MinIO secret | `scripts/dr-minio-secret.sh` | generates `minio-creds` (`MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD`) out-of-band; never committed |
|
|
| Byte-verify drill | `deployments/k8s/base/tidaldb-dr/dr-drill-job.yaml` | export → restore → `sha256` byte-equivalence |
|
|
| Query-proof drill | `deployments/k8s/base/tidaldb-dr/dr-queryproof-job.yaml` | seed → backup → restore → **boot a server on the restore → serve** |
|
|
|
|
The drill image `registry.threesix.ai/tidal/tidalctl:m12-dr-qp` bundles `tidalctl`
|
|
+ `tidal-server` + `mc` (built from `docker/release/dr.Dockerfile`).
|
|
|
|
---
|
|
|
|
## Procedure 1 — Back up a shard to object storage
|
|
|
|
`tidalctl` reads `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` from the env and
|
|
forces path-style addressing (region is the placeholder `"auto"`). Run it where it
|
|
can reach both the data dir and the endpoint (an in-cluster Job for the live shards;
|
|
the live RWO PVCs stay attached to the running StatefulSet, so back up a streamed
|
|
point-in-time copy, not the attached volume).
|
|
|
|
```bash
|
|
AWS_ACCESS_KEY_ID=$MINIO_ROOT_USER AWS_SECRET_ACCESS_KEY=$MINIO_ROOT_PASSWORD \
|
|
tidalctl backup \
|
|
--path /src/db/shard-00000 \
|
|
--out /work/backup \
|
|
--s3-endpoint http://minio.tidaldb-dr.svc:9000 \
|
|
--s3-bucket tidaldb-dr \
|
|
--s3-prefix shard0
|
|
```
|
|
|
|
Writes a local BLAKE3-manifested artifact **and** mirrors every object to
|
|
`s3://tidaldb-dr/shard0/`, uploading `BACKUP_MANIFEST.json` last. Verify the marker
|
|
landed: `mc ls --recursive dr/tidaldb-dr/shard0 | sort -k4` (manifest timestamp must
|
|
be after every data file). Repeat with `--s3-prefix shard1`/`shard2` for the other
|
|
two shards.
|
|
|
|
## Procedure 2 — Restore + byte-verify
|
|
|
|
```bash
|
|
AWS_ACCESS_KEY_ID=$MINIO_ROOT_USER AWS_SECRET_ACCESS_KEY=$MINIO_ROOT_PASSWORD \
|
|
tidalctl restore \
|
|
--path /target/restored \
|
|
--s3-endpoint http://minio.tidaldb-dr.svc:9000 \
|
|
--s3-bucket tidaldb-dr \
|
|
--s3-prefix shard0
|
|
```
|
|
|
|
Restore downloads the prefix, **BLAKE3-verifies every file**, `safe_join`s each path
|
|
(rejects `..`/absolute/backslash), and refuses a non-empty target. Confirm:
|
|
|
|
```bash
|
|
tidalctl status --path /target/restored # status:ok, checkpoint_seq preserved
|
|
# sha256 byte-equivalence vs source (the drill does this for all files)
|
|
```
|
|
|
|
The only expected `diff -r` deltas are empty scaffold dirs (`keyspaces/{2,3}/tables`)
|
|
— they carry no data and are correctly excluded from the file manifest.
|
|
|
|
## Procedure 3 — Query-proof (boot a server on the restore and SERVE)
|
|
|
|
Byte-equivalence proves the *bytes*; this proves the restore *serves*. A raw
|
|
point-in-time stream of a LIVE pod is **not** a standalone-bootable fjall root
|
|
(`fjall recovering … No such file or directory`); a consistency-clean source comes
|
|
from a **graceful shutdown** (SIGTERM → checkpoint + fsync + persist HNSW graphs —
|
|
the only supported way; there is no online checkpoint CLI/HTTP). The
|
|
`dr-queryproof-job.yaml` does this end-to-end:
|
|
|
|
```
|
|
seed (100 items via API) → SIGTERM (clean checkpoint) → tidalctl backup → minio
|
|
→ tidalctl restore → tidal-server standalone --data-dir <restore> → assert it serves:
|
|
/health items == N · /vector_search non-empty · /search?query=<tok> == entity · /feed non-empty
|
|
```
|
|
|
|
Run + read the verdict:
|
|
|
|
```bash
|
|
export KUBECONFIG=~/.kube/orchard9-k3sf.yaml
|
|
kubectl -n tidaldb-dr delete job dr-queryproof --ignore-not-found
|
|
kubectl -n tidaldb-dr apply -f deployments/k8s/base/tidaldb-dr/dr-queryproof-job.yaml
|
|
kubectl -n tidaldb-dr logs job/dr-queryproof # last line: "DR QUERY-PROOF: PASS"
|
|
```
|
|
|
|
---
|
|
|
|
## Production R2
|
|
|
|
Identical `tidalctl` code path — swap the endpoint and supply an R2 token:
|
|
|
|
```bash
|
|
AWS_ACCESS_KEY_ID=<r2-access-key> AWS_SECRET_ACCESS_KEY=<r2-secret> \
|
|
tidalctl backup --path <shard> --out /work/backup \
|
|
--s3-endpoint https://<account-id>.r2.cloudflarestorage.com \
|
|
--s3-bucket tidaldb-dr --s3-prefix shard0
|
|
```
|
|
|
|
> **Blocker:** this needs an **account-level** Cloudflare token with
|
|
> *Workers R2 Storage: Edit* (account `c75bf009a23e747ef34b776ae8095dc4`). The
|
|
> zone/DNS-scoped `THREESIX_CLOUDFLARE_API_TOKEN` in the shell **cannot** mint R2
|
|
> S3 keys (auth error 9109) — it is the one external dependency for live-R2 DR.
|
|
|
|
---
|
|
|
|
## Full-cluster rebuild from backup
|
|
|
|
1. Provision the cluster fresh: `kubectl apply -k k8s/cluster/` (creates ns,
|
|
ConfigMaps, certs, Services, StatefulSet). Do **not** seed yet.
|
|
2. For each of the 3 shards, `tidalctl restore` the latest backup into the
|
|
corresponding pod's `/data/db/shard-0000N` **before** the server opens it (use an
|
|
init step or restore onto the PVC offline). The restore must land a consistency-
|
|
clean dir (a graceful-shutdown backup, not a live stream).
|
|
3. Bring the StatefulSet up; each pod opens its restored shards, the WAL
|
|
`checkpoint.meta` skips replay of already-checkpointed events, and the cluster
|
|
elects a leader and converges. Verify `3/3 Ready`, single leader, and
|
|
`/cluster/status` reports the expected `checkpoint_seq` per shard.
|
|
4. If only one pod's data is lost, do **not** restore from object storage — delete
|
|
that pod's PVC + pod and let it reseed fresh from the live quorum (snapshot
|
|
install → converges lag=0). Object-store restore is for total loss.
|
|
|
|
---
|
|
|
|
## PITR posture
|
|
|
|
- **What exists:** the WAL is gap-free and archived; `tidalctl backup` fences a
|
|
consistent `checkpoint_seq`; restore replays the WAL from the checkpoint forward.
|
|
- **What does NOT exist yet:** arbitrary point-in-time recovery to a chosen seq
|
|
(`tidalctl replay --until <seq>` is not shipped). Recovery lands you at the
|
|
backup's `checkpoint_seq` + whatever WAL was captured, not an arbitrary instant.
|
|
- **Cadence:** run the byte-verify + query-proof drills on every release and on a
|
|
scheduled cadence; record the result in `orchard9-k3sf/deployments/history/tidaldb.md`.
|
|
|
|
---
|
|
|
|
## Quick reference
|
|
|
|
| Need | Command / pointer |
|
|
|---|---|
|
|
| Back up a shard | `tidalctl backup --path <shard> --out <dir> --s3-endpoint … --s3-bucket … --s3-prefix …` |
|
|
| Restore + verify | `tidalctl restore --path <fresh> --s3-endpoint … --s3-bucket … --s3-prefix …` then `tidalctl status --path <fresh>` |
|
|
| Prove it serves | apply `dr-queryproof-job.yaml`, read `DR QUERY-PROOF: PASS` |
|
|
| One pod lost | delete its PVC+pod → reseeds from quorum (no object-store restore) |
|
|
| Total loss | rebuild fresh + `tidalctl restore` each shard before boot |
|
|
| Live R2 | same path + account-level R2 token (the one external blocker) |
|