tidaldb/docs/runbooks/cluster.md
jx12n 1092d34c39 feat: kubernetes deployment, OpenAPI spec, guides, and docker consolidation
- Add k8s/ manifests (StatefulSet, kustomize, PDB, ServiceMonitor) + docs/runbooks/kubernetes.md
- Add tidal-server/src/openapi.rs (utoipa OpenAPI spec) and wire into router
- Add docs/guides/ (build-a-feed-app, embeddings, server-deployment) + foryou_feed example
- Consolidate tidal/docker/ into root docker/ (single canonical home)
- Update API.md, QUICKSTART.md, README.md, CLAUDE.md, check-docs.sh accordingly
2026-06-09 17:06:34 -06:00

445 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# tidalDB Cluster Runbook
Operating the multi-region `tidal-server cluster` surface: launch, the
operational API, replication transport facts, failover and partition drills,
and the honest write-durability contract.
> ## STATUS: EXPERIMENTAL — SINGLE-PROCESS, NOT PRODUCTION HA
>
> Cluster mode runs **every region inside one `tidal-server` process**. The
> regions replicate over the **real `tidal-net` gRPC transport** (`ShipSegment`
> RPC, serialization, circuit breaker, HTTP/2 — see [§4](#4-grpc-replication-transport-tidal-net)),
> but that transport is a **loopback self-loop**: a follower's gRPC server and
> its only peer entry are the same loopback address. So you get faithful
> multi-region replication *semantics* over a real wire, with **no host or
> process isolation**.
>
> **A process crash, OOM, or host failure takes the entire "cluster" down at
> once.** There is no second machine to fail over to. This is a development /
> staging / demo fabric and a correctness harness for the replication paths —
> **it is NOT production high availability.**
>
> True multi-process, multi-host HA (process isolation, real network between
> regions, quorum-acked writes) is tracked as **m8p10** in
> [docs/planning/ROADMAP.md](../planning/ROADMAP.md). Until then, for a real
> production deployment run a **single `tidal-server standalone`** node and back
> it with host-level redundancy and disk durability.
>
> **Cluster mode refuses to start** unless you explicitly opt in with either the
> `--experimental-cluster` flag or the `TIDAL_ALLOW_EXPERIMENTAL_CLUSTER=1`
> environment variable. On start it emits a loud `WARN` restating exactly the
> above. Do not wire it into a production load balancer.
## Scope: what cluster mode does and does not own
tidalDB owns **retrieval and ranking only**. Cluster mode adds multi-region
replication of that, nothing more. It does **not** generate embeddings, store
video/blobs, run a CDN or transcoder, do auth/moderation/payments, or provide a
distributed consensus log. Bring your vectors; tidalDB retrieves and ranks over
them. See [VISION.md](../../VISION.md) for the scope boundary.
## Prerequisites
- Rust toolchain ≥ 1.91 if running directly (the Docker build pins
`rust:1.91-bookworm`).
- `protobuf-compiler` (`protoc`) and a C++ toolchain (`g++`) on the build host —
`tidal-net`'s build script compiles the WAL-shipping `.proto`, and USearch's
HNSW core is C++. The Docker image installs both.
- Docker 25+ if running via container.
- Port `9500` available (default cluster HTTP listener). Each follower region
also binds an **OS-assigned loopback port** for its gRPC replication server
unless you pin one in the topology (see [§3](#3-topology-yaml)).
## 1. Launch the cluster locally
Cluster mode is gated. Pass `--experimental-cluster` (or set
`TIDAL_ALLOW_EXPERIMENTAL_CLUSTER=1`) or the server exits with an error
explaining why.
```bash
TIDAL_ALLOW_EXPERIMENTAL_CLUSTER=1 \
cargo run -p tidal-server -- \
cluster \
--listen 127.0.0.1:9500 \
--schema tidal-server/config/default-schema.yaml \
--topology tidal-server/config/default-cluster.yaml \
--experimental-cluster
```
The default topology spins up three regions (`us-east`, `eu-west`, `ap-south`)
with `us-east` as leader. On a clean start you will see a `WARN` line stating
this is experimental and single-process, an `info` line per follower
(`follower gRPC transport ready`), and finally `listening on http://127.0.0.1:9500`.
**Auth:** set `TIDAL_API_KEY=<secret>` to require `Authorization: Bearer <secret>`
on the data and `/cluster/*` mutation routes. If it is **unset the server runs
UNAUTHENTICATED and logs a WARN** — never expose an unauthenticated cluster
beyond loopback. Health probes and `/openapi.json` are always unauthenticated.
Useful environment variables (shared with standalone):
| Var | Effect |
|-----|--------|
| `TIDAL_ALLOW_EXPERIMENTAL_CLUSTER=1` | Opt in to cluster mode (alternative to `--experimental-cluster`). |
| `TIDAL_API_KEY` | Bearer token for protected routes. Unset ⇒ unauthenticated + WARN. |
| `TIDAL_CONFIG` | Config dir holding `default-schema.yaml` / `default-cluster.yaml` (used when `--schema` / `--topology` omitted). |
| `PORT` | Listen address. A bare port (`9500`) normalises to `0.0.0.0:9500`. |
| `TIDAL_SERVER_LOG` | `tracing` filter (default `info`). |
## 2. Launch via Docker
```bash
# Build the image once (build context is the repo root).
docker build -f docker/cluster/Dockerfile -t tidaldb:cluster .
# Run (press Ctrl+C to stop). The image already sets
# TIDAL_ALLOW_EXPERIMENTAL_CLUSTER=1 so cluster mode starts; it still logs the
# loud experimental WARN.
docker run --rm -p 9500:9500 tidaldb:cluster
```
The image bakes the default schema/topology under `/etc/tidal-server` and uses
`ENTRYPOINT ["tidal-server"]` + a `cluster …` `CMD`, so you can override the
subcommand (e.g. `docker run tidaldb:cluster standalone …`) or supply your own
config files:
```bash
docker run --rm -p 9500:9500 \
-e TIDAL_API_KEY=changeme \
-v "$PWD/configs/my-schema.yaml:/srv/schema.yaml:ro" \
-v "$PWD/configs/my-topology.yaml:/srv/topology.yaml:ro" \
tidaldb:cluster \
cluster \
--listen 0.0.0.0:9500 \
--schema /srv/schema.yaml \
--topology /srv/topology.yaml
```
The container ships a `HEALTHCHECK` that curls `/health`. For Kubernetes/Compose
deployment patterns see [Cross-references](#cross-references).
## 3. Topology YAML
The topology file declares regions and the leader. Minimal default
(`tidal-server/config/default-cluster.yaml`):
```yaml
regions:
- name: us-east
- name: eu-west
- name: ap-south
leader: us-east
# Optional: OS worker threads serving cluster write/heal requests (gRPC ship).
# Defaults to available parallelism when omitted.
# write_workers: 4
```
Fields:
| Field | Required | Meaning |
|-------|----------|---------|
| `regions[].name` | yes | Region name used everywhere in the HTTP API (`?region=`, `/cluster/promote`, etc.). Must be unique. |
| `regions[].grpc_addr` | no | This region's gRPC replication bind address, e.g. `"127.0.0.1:9601"`. **Omit it** for the single-process default — the server allocates a free loopback port and self-heals a bind race by retrying on a fresh port. An explicit address is only needed for the future multi-process deployment (m8p10) and is tried exactly once. |
| `leader` | yes | Must name one of the declared regions. Writes route here. |
| `write_workers` | no | Size of the runtime-free OS-thread pool that runs the blocking gRPC segment-ship on `/signals` and `/cluster/heal`. Bounds write concurrency on the hottest path. |
The schema YAML (signals / text_fields / embedding_slots / profiles) is the same
format the standalone server loads — see [API.md](../../API.md) and
[QUICKSTART.md](../../QUICKSTART.md) for the full schema/profile grammar and the
25 built-in ranking profiles. Topology only adds the region layout above.
> **Personalization correctness note.** Preference-vector personalization (the
> taste vector that powers `for_you`) only updates for signals declared
> `positive_engagement: true` in the schema. The cluster `/signals` route writes
> **global** signals (no `user_id` / `creator_id` context), so it never folds
> item embeddings into a user's taste vector regardless of the
> `positive_engagement` flag. Personalized writes go through the embedded engine
> (`signal_with_context`), not this HTTP route.
## 4. gRPC replication transport (tidal-net)
Replication between regions is the real `tidal-net` `WalShipping` gRPC service,
not in-process channels. The facts you need to operate and firewall it:
| Property | Value | Notes |
|----------|-------|-------|
| Service | `WalShipping` (proto `tidal.replication.v1`) | RPCs: **`ShipSegment`** (unary; the production replication path) and **`Heartbeat`** (ControlPlane health). `StreamSegments` is declared but **not implemented** — segment delivery is the unary `ShipSegment`. |
| Default port | `59529` | In tidalDB's reserved dev band `5952059529`. In single-process cluster mode each follower binds an **auto-allocated** loopback port instead (or the topology's `grpc_addr`). |
| Transport security | mTLS via `TlsConfig` (`ca_cert`, `server_cert`, `server_key`, optional `client_cert` / `client_key`) | `insecure = true` (plaintext) is the single-process loopback default. A peer with no TLS config **must** set `insecure`. |
| Max payload | **64 MiB** | Both encoder and decoder codec limits are raised to this; pinned by a compile-time assert to the engine's `InProcessTransport` limit so both ends agree. WAL segments default to 16 MiB. |
| Circuit breaker | per-peer, **threshold 5**, **reset 30s** | After 5 consecutive ship failures the breaker opens; it stays open for 30s, then the next attempt probes (HalfOpen → Closed on success, Open again on failure). Backpressure (channel full) does **not** trip it. |
| Timeouts (defaults) | connect 5s, request 10s, keep-alive PING every 10s / 5s ACK | A blackholed peer fails fast instead of stalling the single-threaded shipper. |
`ShipSegment` carries the WAL segment id, BLAKE3-validated payload bytes, event
count, and the leader's authoritative `leader_last_seq` (so a follower can
advance its replication-lag high-water-mark even for an all-local segment that
filters to empty). The follower's segment-receiver thread drains and applies it
on arrival.
> **Operational guard:** the gRPC replication ports and the Prometheus `/metrics`
> endpoint are **UNAUTHENTICATED**. Bind them to loopback or a cluster-internal
> network only — never the public interface.
## 5. Core HTTP API
All routes are JSON unless noted. Examples assume `BASE=http://localhost:9500`
and, when `TIDAL_API_KEY` is set, `AUTH='-H "Authorization: Bearer $TIDAL_API_KEY"'`.
Drop the `-H` header when running unauthenticated.
Middleware mirrors standalone: 30s request timeout (408), 100 max in-flight
(429), 2 MB body limit (413), and an `x-request-id` on every response. Health
probes sit **outside** the load-shedding stack so liveness/readiness are never
queued or timed out under saturation.
### Health
```bash
curl "$BASE/health" # 200 ok / 503 while draining; reports leader + item count
curl "$BASE/health/startup" # always 200
curl "$BASE/health/live" # always 200
curl "$BASE/openapi.json" # served OpenAPI 3.1, UNAUTHENTICATED — canonical HTTP reference
```
The cluster `/openapi.json` is a superset that documents the `/cluster/*` and
`/sharded/*` routes. It is the machine-readable source of truth for request /
response shapes.
### Register items & embeddings (leader-routed)
```bash
curl -X POST "$BASE/items" \
-H 'Content-Type: application/json' \
-d '{ "entity_id": 1, "metadata": { "title": "Jazz Piano", "category": "music" } }'
# → 201 Created
curl -X POST "$BASE/embeddings" \
-H 'Content-Type: application/json' \
-d '{ "entity_id": 1, "values": [0.1, 0.2, 0.3, 0.4] }'
# → 204 No Content
```
Embeddings: tidalDB does **not** generate vectors — the caller brings them. The
write L2-normalizes and inserts into the HNSW index. Dimensions are **strict**:
they must equal the slot's declared dimensions (min 2, max 4096) or the insert
fails, and **zero-norm vectors are rejected**. `RETRIEVE` / `SEARCH` route
through the **first declared embedding slot only**; multi-modal apps must fuse
offline or use separate entity kinds.
### Record signals (cluster `/signals` = global only)
```bash
curl -X POST "$BASE/signals" \
-H 'Content-Type: application/json' \
-d '{ "entity_id": 1, "signal": "view", "weight": 1.0 }'
# → 204 No Content (see the durability contract in §8)
```
The signal name must be declared in the schema. This route records a **global**
signal on the leader and ships it to followers; it does not personalize (see the
personalization note in [§3](#3-topology-yaml)).
### Retrieve and search (region-pinned reads)
```bash
# Default read region is the leader.
curl "$BASE/feed?user_id=42&profile=for_you&limit=20"
curl "$BASE/search?query=jazz%20piano&user_id=42&limit=5"
# Pin a read to a specific region. Followers may lag the leader (and lag jumps
# during a partition) — use this for canary reads and lag verification.
curl "$BASE/feed?profile=trending&region=eu-west"
```
`?region=` accepts any declared region name; an unknown name returns 400. Omit
it and the read serves from the current leader. `limit` is clamped to 1000 at the
trust boundary. The `for_you` profile applies the built-in diversity defaults
(`max_per_creator=2`, `format_mix_max_fraction=0.4`, `exploration=0.1`).
## 6. Cluster management API
### Check cluster status
```bash
curl "$BASE/cluster/status" | jq
```
```json
{
"leader": "us-east",
"relay_log_len": 125,
"regions": [
{ "name": "us-east", "applied_events": 125, "lag_events": 0, "partitioned": false },
{ "name": "eu-west", "applied_events": 125, "lag_events": 0, "partitioned": false },
{ "name": "ap-south", "applied_events": 124, "lag_events": 1, "partitioned": false }
]
}
```
`lag_events` is `relay_log_len applied_events` (saturating). A non-zero,
*growing* lag on a region is the signal that it is partitioned or its
segment-receiver is wedged.
### Promote a new leader
```bash
curl -X POST "$BASE/cluster/promote" \
-H 'Content-Type: application/json' \
-d '{ "region": "eu-west" }'
# → { "ok": true, "leader": "eu-west" }
```
After promotion `/cluster/status` reports the new leader; new writes route there
and replay to the other regions. An unknown region returns 400.
### Simulate a partition & heal
```bash
# Isolate ap-south: leader ships skip this follower, so its lag climbs.
curl -X POST "$BASE/cluster/partition" \
-H 'Content-Type: application/json' \
-d '{ "region": "ap-south" }'
# → { "ok": true, "partitioned": "ap-south" }
# Heal: missed segments are re-shipped over gRPC (blocking, offloaded to the
# write pool — a saturated pool degrades to 429, not 500).
curl -X POST "$BASE/cluster/heal" \
-H 'Content-Type: application/json' \
-d '{ "region": "ap-south" }'
# → { "ok": true, "healed": "ap-south" }
```
## 7. Sharded scatter-gather API
The `/sharded/*` routes hash-partition entities across regions
(`hash(entity_id) % num_shards`, using the engine's own `ShardRouter` so writes
and reads never disagree) and fan a query out to **all** shards, K-way merging
the per-shard results by score.
Write routes (each writes to the single owning shard):
```bash
curl -X POST "$BASE/sharded/items" -d '{ "entity_id": 7, "metadata": { "title": "..." } }' # 201
curl -X POST "$BASE/sharded/embeddings" -d '{ "entity_id": 7, "values": [0.1,0.2,0.3,0.4] }' # 204
curl -X POST "$BASE/sharded/signals" -d '{ "entity_id": 7, "signal": "view", "weight": 1.0 }' # 204
```
Read routes (scatter-gather across all shards):
```bash
curl "$BASE/sharded/feed?profile=for_you&limit=20&deadline_ms=50"
curl "$BASE/sharded/search?query=jazz&limit=10&deadline_ms=100"
```
Each sharded read accepts an optional `deadline_ms` — the **total** scatter
budget (default 50ms, server-clamped to 10s). The per-shard deadline is
`deadline_ms 5ms` network overhead. The response includes a `scatter_gather`
block:
```json
{
"items": [ /* merged, deduped, diversity-enforced, re-ranked */ ],
"total_candidates": 4210,
"scatter_gather": {
"degraded": false,
"shards_queried": 3,
"elapsed_ms": 12,
"shard_deadline_ms": 45
}
}
```
Degraded semantics: a shard that is partitioned, errors, or misses the deadline
is reported in `unavailable_shards` (a name list) and flips `degraded: true`
it is **never silently dropped**. The merge dedups replicated copies of an entity
(keeping the best-scoring copy), reconciles `total_candidates` so replicated
shards are not counted multiple times, and re-enforces `max_per_creator` across
the merged set.
## 8. Write-durability contract (read this before trusting a 204)
A `204 No Content` from `/signals` (and the data writes) means **the write is
durably applied on the leader** — storage updated plus **WAL fsync** — and
nothing more.
- The follower ship is **best-effort**. A ship that fails is logged engine-side
and queued for re-delivery via the heal / convergence path. The 204 does
**NOT** assert quorum and does **NOT** assert any follower acknowledged the
write.
- If the single process crashes after the 204 but before followers caught up,
the leader's WAL still has the write (it survives restart from disk); the
followers will reconcile on the next ship/heal. But because there is **only
one process**, a crash is total downtime, not a failover — see the status box.
- A quorum-ack write contract is explicitly future work, tracked with the
multi-process cluster effort (**m8p10**).
In short: **204 = leader durability, not cluster durability.** Design your
client retries accordingly (the writes are idempotent on `entity_id` + signal).
## 9. Failover drill
1. **Baseline.** `GET /cluster/status`; confirm the expected leader and
`lag_events: 0` on every region.
2. **Pre-seed reads.** Issue a region-pinned read against the target region
(`?region=eu-west`) to confirm it is serving and roughly caught up.
3. **Promote.** `POST /cluster/promote { "region": "eu-west" }`.
4. **Verify.** `GET /cluster/status` now reports `eu-west` as leader. Send a
write (`POST /signals`) and confirm `relay_log_len` advances and the other
regions' `applied_events` follow within a heartbeat.
5. **Cut over traffic.** Point your client's writes at the same `$BASE` (the
single surface routes writes to whoever is leader) — no client change needed.
> Reminder: this is a *leadership move within one process*, not a host failover.
> It is the right drill for "move the write region during maintenance," not for
> "survive a machine dying."
## 10. Partition drill
1. **Baseline.** `GET /cluster/status`; all `lag_events: 0`, all
`partitioned: false`.
2. **Inject.** `POST /cluster/partition { "region": "ap-south" }`. Confirm
`partitioned: true` for that region.
3. **Write through it.** Send several `POST /signals`. Watch `ap-south`'s
`lag_events` climb while `applied_events` stalls — the leader's ships to it
are dropped.
4. **Read the stale follower.** `GET /feed?region=ap-south` should return the
pre-partition view (demonstrating eventual, not strong, read consistency).
5. **Heal.** `POST /cluster/heal { "region": "ap-south" }`. Missed segments
re-ship over gRPC.
6. **Verify convergence.** Poll `GET /cluster/status` until `ap-south`'s
`lag_events` returns to 0 and `partitioned: false`.
7. **Scatter-gather degradation.** While partitioned, a `GET /sharded/feed`
should report `degraded: true` with `ap-south` in `unavailable_shards` — not
an error. Confirm it clears after heal.
## 11. Shutdown
Send `SIGTERM` (or `SIGINT` / Ctrl+C, or stop the container). The server flips
readiness to 503, drains in-flight requests, then drops the cluster — which runs
each region's `TidalDb` shutdown path: checkpoint in-memory signal state → flush
storage → write the WAL checkpoint marker + **fsync** → join the WAL, sweeper,
checkpoint, and text-syncer threads. The drop is idempotent. You will see
`cluster shutdown: closing all nodes (checkpoint + WAL fsync)`.
## Cross-references
- **Kubernetes deployment** — `docs/runbooks/kubernetes.md` *(planned for the
m8p10 multi-process work; not yet written — until then deploy a single
standalone node per the production guidance in the status box, and use the
Docker `HEALTHCHECK` / `/health` probe as the readiness gate).*
- **Server deployment guide** — `docs/guides/server-deployment.md` *(planned;
not yet written — see [API.md](../../API.md) and [QUICKSTART.md](../../QUICKSTART.md)
for the current server config and route reference).*
- **Monitoring & alerts** — [docs/ops/monitoring.md](../ops/monitoring.md)
(Prometheus scrape config, replication-lag and WAL metrics, recommended
alerts; remember the `/metrics` endpoint is unauthenticated — bind it
internally).
- **Roadmap / m8p10 (true multi-process HA)** —
[docs/planning/ROADMAP.md](../planning/ROADMAP.md) for the distributed-fabric
status, the m8p1m8p9 completed phases, and the outstanding multi-process /
tier-3 chaos work.
- **API & schema reference** — [API.md](../../API.md),
[QUICKSTART.md](../../QUICKSTART.md), and the live `/openapi.json` document.
- **Scope & vision** — [VISION.md](../../VISION.md).
```