tidaldb/docs/runbooks/on-call.md
jx12n 25296bcc5b docs: refresh ops runbooks to the live rc7 / full-placement reality
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.
2026-06-19 19:53:29 -06:00

6.2 KiB
Raw Blame History

tidalDB On-Call & Incident Response

First page to open during a tidalDB incident. Routes a symptom → the golden signal that confirms it → the runbook that fixes it. For the operational API see cluster.md; for k8s mechanics kubernetes.md; for backup/restore disaster-recovery.md.

Ownership: the tidalDB maintainers (source: git.threesix.ai/jordan/tidaldb). The live cluster runs in orchard9-k3sf (ns tidaldb-cluster); deployment history is orchard9-k3sf/deployments/history/tidaldb.md; live state is orchard9-k3sf/cluster-state.yaml.


0. First 60 seconds — orient

export KUBECONFIG=~/.kube/orchard9-k3sf.yaml
kubectl get pods -n tidaldb-cluster -l app.kubernetes.io/name=tidaldb -o wide   # 3/3 Ready? restarts?
# health + leader (HTTPS + mTLS plane), via port-forward:
kubectl port-forward -n tidaldb-cluster tidaldb-0 19500:9500 >/dev/null 2>&1 &
curl -fsSk https://127.0.0.1:19500/health          # ok:true + agreed leader across pods
curl -fsSk https://127.0.0.1:19500/cluster/status  # per-shard leaders, commit index, quarantine, reseed

Golden signals (Grafana "tidalDB Overview" → Cluster Replication row, or /metrics):

Signal Metric Healthy
Per-peer circuit breaker tidaldb_cluster_peer_breaker_state 0 (closed). 1 (open) on a live peer = the write-burst/partition pattern.
Commit progress / quorum lag relay_durable_seq vs relay_last_seq lag bounded, advancing under load
Reseed pending reseed_required 0
Election churn leader_changes_total, election_term flat
Quorum timeouts quorum_timeouts_total flat (a rising rate = writes can't reach quorum)
Divergence divergence_quarantined 0

1. Severity

SEV Definition Examples
SEV1 Writes unavailable or acked data at risk quorum lost (≥2 voters down or both breakers stuck open), divergent prefix served, total cluster loss
SEV2 Degraded but serving one voter down (still 2/3 quorum), read p99 > 10 ms sustained, a pod CrashLooping, write-burst 503s on an old image
SEV3 No user impact yet single transient reseed, elevated election term, one breaker briefly open then clears, soak night failed

2. Symptom → signal → runbook

Symptom Confirm with Action
Pod CrashLooping ("reseed marker latched") kubectl logs, reseed_required Reseed-loop is resolved on the live image (rc5+); a genuine reseed snapshot-installs and converges lag=0. If looping on an OLD image → roll to the live digest. [cluster.md reseed]
Writes 503 / quorum lost /cluster/status (voters down?), quorum_timeouts_total rising One voter down is survivable (2/3). Both followers down → honest retryable 503 until one returns. Restart/replace the down voter; check node health. [cluster.md §9]
Write-burst 503-storm, no self-heal; both breakers stuck at 1 while peers alive tidaldb_cluster_peer_breaker_state == 1 on live (heartbeating) followers Fixed in rc7 (write-burst false-partition). If on an older image, roll to ≥ rc7. Confirm the gauge clears as load eases. [cluster.md §9.1]
Stuck leader / dual-leader view (leader sees peers partitioned, reads 408) /cluster/status disagrees across pods Restart the leader pod to force re-election; auto-election converges. [cluster.md §9]
Divergence / node serving a stale prefix divergence_quarantined > 0 The node auto-quarantines (503 readiness) and reseeds without wiping — it never serves a divergent prefix. Let it reseed; verify it rejoins lag=0. [cluster.md §9.1]
Read p99 > 10 ms, reads hang Grafana read-latency; check load spread Read-SLA collapse was CPU oversubscription, fixed in rc12 (SEARCH_GATE + parallel scatter + cpu limit 3). Ensure reads are spread round-robin across all 3 pods; check the ~1000-rps read ceiling isn't exceeded. [capacity-planning.md]
OOMKilled pod lastState.terminated.reason=OOMKilled 1536-D full placement peaks ~1.9 GiB at 100k; 1M needs > 16 GiB nodes (≈78 GB/pod). Do not raise the limit past node headroom — it's a node-size limit. [capacity-planning.md]
One pod's PVC lost/corrupt pod won't open its data dir Delete that pod's PVC + pod → it reseeds fresh from the live quorum (snapshot install), converges lag=0. Do NOT object-store-restore for a single-pod loss. [ops/recovery.md]
Total cluster loss Rebuild fresh + tidalctl restore each shard before boot. [disaster-recovery.md]
Soak night failed kubectl get jobs -n tidaldb-cluster -l app.kubernetes.io/name=tidal-soak A FAIL resets the 30-night streak. Read the night's log for the SLO breach (p99 > 150 ms or error > 1%) or an under-load restart. [cluster.md §15]

3. Alerting status (action required)

The alert rules in ../ops/prometheus-alerts.yaml are accurate but DESIGN-REFERENCE — they are not yet loaded by the live Prometheus. The rules already cover the must-watch signals (TidalDBClusterBreakerOpen on peer_breaker_state == 1, TidalDBClusterCommitIndexStall, TidalDBClusterElectionChurn, TidalDBClusterQuorumTimeouts, DivergenceQuarantine, ReseedPending).

Open infra step: promote them into the observability stack as a PrometheusRule (or vmalert rule file) so they actually page. Until then, on-call watches the Grafana Cluster Replication row manually. This is the one item that is an observability deployment, not a doc fix — track it in orchard9-k3sf.


4. Escalation

  1. Page the on-call operator → run §0, classify per §1.
  2. SEV1 (acked data at risk / writes down): stop further rollouts, capture /cluster/status + breaker gauge + kubectl logs from all 3 pods, then engage the tidalDB maintainers. Do not delete PVCs or force-promote under a divergence/quorum-loss condition without confirming the data-safety path in cluster.md — the system is designed to quarantine rather than serve bad data; let it.
  3. Record the incident + resolution in orchard9-k3sf/deployments/history/tidaldb.md (newest first).