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

6.6 KiB
Raw Blame History

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.

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.