tidaldb/docs/profiling/m12p4-t5-sharded-throughput.md
jx12n 31ee612f27 feat(m12p4): sharded ingestion — scatter-gather pool + cross-shard unified reads (L4)
Scale write throughput across data-shard groups while keeping a single unified
read surface:

- scatter_gather.rs: pooled fan-out across shard groups (replaces per-request
  client construction); cross-shard query results merged on one node
- cluster/node.rs: cross-shard read routing — a read on any node gathers from
  every shard group's leader and unions results
- cluster/forward.rs: fix h2 204 forward-relay bug (relay_forwarded skips body
  for 1xx/204/304 — synthesized JSON body on a 204 triggered HTTP/2 RST_STREAM
  on the real mTLS plane)
- dto.rs: cross-shard query/result DTOs
- k8s/cluster/: enable 3-group `shards:` topology (statefulset, service-peers,
  topology-configmap)
- k8s/cluster-local-kind/: local-kind overlay to run the T5 gate without Ref-A
- tidal-stress/k8s/stress-job-t5.yaml: 2-generator sharded throughput job
- tests: cluster_cross_shard_reads.rs + multiproc support; ran real on kind
- docs/profiling/m12p4-t5-sharded-throughput.md: T5 throughput findings
2026-06-14 15:17:35 -06:00

113 lines
6.6 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.

# m12p4 / T5 — Sharded ingestion at scale (real-cluster run, 2026-06-14)
**What ran.** A real 3-shard-group × RF=3 tidalDB cluster on a local `kind`
cluster (single node, 24 vCPU / 50 GB), full mTLS (cert-manager-issued certs),
the m12p4 server image, driven by **two** in-cluster `tidal-stress` generator
pods (the "second stress generator" m12p4 calls for). Not a simulation — real
binaries, real WAL + quorum, real gRPC replication, real inter-node mTLS HTTP.
This is the honest record of what the run **proved**, what it **found**, and why
the headline gate (`≥2.5× scaling AND ≥5000 quorum writes/s`) remains
**Ref-A/k3s-pending** — now with data and a sharper reason, not just a hardware
caveat.
## What the run proved (real, reproducible)
- **The 3-group topology deploys and serves on real k8s.** `shards:` enabled
(`k8s/cluster/topology-configmap.yaml`), every pod binds a derived gRPC port
per group (9601/9602/9603), `/cluster/status` shows **balanced leaders**
shard 0→tidaldb-0, 1→tidaldb-1, 2→tidaldb-2, all term 0. Fresh parallel boot
converges in **~3048 s**. (Reproduce: `k8s/cluster-local-kind/` overlay.)
- **Cross-group quorum writes work end-to-end** — a write for an entity owned by
another group's leader is hash-routed + forwarded over the inter-node mTLS
plane and quorum-acked, `x-tidal-seq` verdict header riding back.
- **Graceful behavior under overload** — at offered loads past the engine knee,
the cluster sheds with 503 backpressure / request timeouts; **0 pod crashes,
0 acked loss** (a timed-out write is never acked, so it is not acked loss).
## The blocker this run uncovered and fixed (HTTP/2 forward relay)
The FIRST cross-group write returned curl **exit 92 (HTTP/2 stream error)** /
HTTP 000; the identical write with `--http1.1` returned 204. Root cause: the
inter-node/client plane serves h2 (ALPN `h2`+`http/1.1`), `/signals`//`/embeddings`
return **204 No Content**, and the forward relay (`forward::relay_forwarded`)
attached a **synthesized JSON body to a 204** (`forward_json_with_headers` fills
an empty peer body). HTTP/2 RST_STREAMs a 204 that carries a DATA frame; HTTP/1.1
tolerated it, which masked the bug until the sharded forward ran over the **real
h2 plane** (never exercised before — was itself Ref-A/k3s-pending). Fixed in
`relay_forwarded` (skip the body for 1xx/204/304 via `status_forbids_body`) +
3 unit tests; the one direct relay site now routes through the helper too. After
the fix, all cross-group writes return 204 over h2.
This is a genuine correctness fix that only a real-h2-plane run surfaces —
in-process / plaintext multiproc e2e (reqwest→plaintext = h1.1) cannot catch it.
## Throughput numbers (measured)
Open-loop, coordinated-omission-corrected, `--ack quorum`, `--mix writes`,
`--embedding-dim 1536` (schema width). "Sustained" = highest ramp stage at
≤1% error; aggregate = sum of the two generator pods.
| Config (per-pod CPU) | Sustained quorum writes/s (≤1% err) | Behavior past the knee |
|----------------------|-------------------------------------|------------------------|
| **1 group, RF=3** (2) | **~5,500** (2×~2,775) | latency rises to p99 ~280 ms, 503s — single leader saturates |
| **3 groups, RF=3** (2) | **~4,0005,000** | collapses earlier (stage 2: 78% shed, 40 s timeouts) |
Both generators are **identical** and cap at **~2,3003,900 rps each** — the knee
is the generator's in-flight×latency limit (`client-shed`), not engine errors, at
the lower stages. Two generators offer **at most ~7,800 rps aggregate**.
## The finding: full-placement sharding does not scale writes at fixed per-pod CPU
The roadmap premise — "3 groups → ~3× writes" — assumes the bottleneck is the
**single-leader funnel** (one leader serializing WAL append + quorum), which
sharding parallelizes across S leaders. That holds **only when per-pod CPU is not
the binding constraint.**
With **full placement** (every pod replicates EVERY group — the shape chosen for
failover simplicity), each pod runs **all S groups**: leader for 1 + follower for
S1. So a pod's total replication work scales with **S**, while its CPU is fixed.
At 2 vCPU/pod the 3-group pods are doing 3 groups' WAL/fsync/ship/apply in the
same budget as the 1-group pods do 1 group's — so the 3-group cluster saturates
at a **comparable or lower** aggregate than single-group, not 3× higher. Measured:
3-group ≈ single-group (~45k), then 3-group collapses *first* under overload.
**Implication.** Full-placement sharding buys **failover** (any pod loss keeps
every group's quorum) and **leader-funnel relief**, but it does **not** raise
aggregate write throughput at a fixed cluster CPU budget — the per-pod replication
overhead grows with S. Real write *scaling* needs **partitioned placement** (each
pod hosts a SUBSET of groups, so adding pods adds both groups and CPU) and/or
per-pod CPU headroom so the leader funnel — not per-pod CPU — is the bottleneck.
## Why the gate stays Ref-A/k3s-pending (now with data)
To demonstrate `≥2.5× scaling AND ≥5,000/s` you need ALL of:
1. **Per-pod CPU headroom** so the single-leader funnel (not per-pod CPU) binds —
i.e. enough cores that a pod running S groups isn't CPU-saturated.
2. **Generators that can offer ≥14k rps** — 2.5× over the ~5.5k single-group
ceiling. Two TLS generators cap at ~7.8k aggregate; that's ~1.4× at most.
3. **Or partitioned placement** across more nodes (the architecturally correct
write-scaling shape), which a single kind node cannot host meaningfully.
A single shared 24-vCPU laptop cannot satisfy (1)+(2) simultaneously — the engine
and the TLS generators contend for the same cores. This is precisely the
hardware dependency the roadmap names; the run confirms it with numbers and adds
the full-placement insight above.
## Artifacts (committed)
- `k8s/cluster/topology-configmap.yaml` — 3-group `shards:` block enabled.
- `k8s/cluster/statefulset.yaml`, `service-peers.yaml` — derived per-shard ports.
- `k8s/cluster-local-kind/` — local-kind overlay (image + storageclass override).
- `tidal-stress/k8s/stress-job-t5.yaml` — the 2-generator T5 Job (production shape).
- `tidal-server/src/cluster/forward.rs` — the h2 204-relay fix + tests.
## Recommended follow-up (for the visionary)
The T5 gate as written assumes full-placement scales writes; the measured reality
says it scales failover, not write throughput, at fixed CPU. Re-scope T5 to either
(a) **partitioned placement** on multi-node k3s/Ref-A (the real write-scaling
proof), or (b) restate the gate as "leader-funnel relief at CPU headroom" and size
the pods accordingly. Either way the generator fleet must be ≥4 pods to offer the
load a scaled cluster can absorb.