tidaldb/docs/profiling/m12p5-idle-readiness-elasticity.md
jx12n 4db3f1e597 fix(m12p6): complete T4 TLS scale-up — two-tier PKI + join_boot grpc_tls fallback
Completes the seed-join-over-TLS enablement begun in 8e39ee1. A real
kubectl scale 3->5 on a real mTLS k8s cluster (kind) exercised the seed-join
path over TLS for the first time and surfaced two more blockers beyond 8e39ee1's
https-seed / ready-only-Service / up-front-rustls-provider fixes — both of which
crash-looped every scale-up joiner with the same opaque 'could not join within
120s'. The plaintext in-process harness is blind to all of them.

- certs.yaml: a real TWO-TIER PKI. The leaf was issued DIRECTLY from a selfSigned
  Issuer (a self-signed CA:FALSE end-entity whose ca.crt is a copy of the leaf);
  the joiner's strict webpki verifier rejected the peer cert as UnknownIssuer.
  Now: selfSigned Issuer -> CA cert (CA:TRUE) -> ca: Issuer signs the leaf.
  (scripts/gen-cluster-certs.sh already did this; the two were inconsistent.)
- join_boot.rs: grpc_tls_for() fallback. own_grpc_tls/self_tls_spec looked up the
  joiner's OWN region in the knob file to find its TLS material, but a seed-joiner
  is NEVER in the shared-ConfigMap regions: list -> None -> the seed client built
  with NO CA (the real UnknownIssuer cause) and a plaintext synthesized topology.
  Fall back to ANY region's block (every pod mounts the same cert files).
- join_boot.rs: STATUS_POLL_TIMEOUT 500ms -> 5s (env TIDAL_SEED_STATUS_TIMEOUT_MS);
  a cold TLS handshake under contention blew the sub-second budget. Discovery now
  logs each poll failure at WARN with the full error source chain (a silent loop
  made every bug present as the same 120s timeout).
- statefulset.yaml: pin the m12-8e39ee1 server image (carries these fixes).
- k8s/cluster-t4-kind + tidal-stress/k8s/t4-*: local-kind T4 overlay + seed/load.

Verified GREEN on kind: idle scale 3->5, both joiners seed-join over mTLS, catch
up, and flip /health Ready in 13s via the idle-readiness heartbeat convergence;
auto-promote to Voter; full content parity; all 5 regions lag=0. clippy clean;
mp_seed_join_snapshot_catchup + mp_idle_cluster_..._without_traffic green;
tidal-server/tidal-net lib green. A separate, root-caused snapshot-frontier bug
on a DEEPLY-compacted WAL (node.rs:734 last_wal_seq=0 for a state-only artifact)
is documented as a follow-up — left unfixed because a naive patch broke the
in-process snapshot test (own-WAL<->stream numbering); the GREEN run uses a small
corpus (stream catch-up) to keep that path out of scope. See
docs/profiling/m12p5-idle-readiness-elasticity.md §6.
2026-06-14 22:41:59 -06:00

218 lines
13 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.

# m12p5 — Idle-readiness fix + elasticity under load (T4)
Status as of 2026-06-14. Closes the **idle-readiness stall** and the **cert SAN
scale gap**; the 1M/1536-dim T4 run on k3s remains the project's standing
Ref-A/k3s dependency (the machinery and the fix that unblocks it are proven
locally over real OS processes).
## 1. The idle-readiness stall (WORKLOG 2026-06-13: an 11.5h hang)
A snapshot-installed joiner (`install_boot` / `seed_joiner`) serves `503` on its
readiness probe (`/health` → `region_health``is_ready`) until its sticky
`converged` latch flips. Pre-m12p5 the ONLY thing that flips that latch is
`note_lag_for_readiness`, and it is called from exactly one place:
`local_status` — i.e. when something hits the node's `/cluster/status/local`
(an operator/monitoring poll, or the leader's `/cluster/status` aggregator
querying it).
On a cluster with write traffic this is masked: ship traffic keeps the lag gauge
fresh and monitoring keeps poking status. On a **fully idle** cluster (no writes,
no status polls) neither happens, so a freshly caught-up joiner sits `503`
forever and never joins the Service VIP. The k8s `readinessProbe` is `/health`
(`statefulset.yaml`), so the pod never receives traffic — an 11.5h observed stall.
Root-cause specifics (read on `44b768b`):
- `node.rs::note_lag_for_readiness` sets `converged` only when a lag value ≤
`learner_promote_lag` is *recomputed* — there is no periodic self-driven check.
- `region_health` (the readiness handler) reads `is_ready()` but never recomputes
lag, so polling readiness does not advance convergence.
- The lag gauge (`leader_seqno_for(shard)`) is seeded only by **received ship
segments**; on idle nothing ships, so the gauge would even read a stale/0 value
— converging on it directly is unsafe (a behind-but-unshipped joiner would read
lag 0 from an uninitialized gauge and false-converge).
## 2. The fix — converge from the heartbeat
The leader **heartbeat** flows every heartbeat interval regardless of write
traffic and already proves "the leader is alive at term T". m12p5 makes it also
carry the leader's **live flushed frontier**:
- **Proto** (`wal_shipping.proto`): new `HeartbeatRequest.leader_last_seq = 14`
the leader's `ship_feed.flushed_seq()` at heartbeat time (same stream numbering
as a follower's per-shard `applied_seqno`). proto3 zero-default `0` = a pre-m12p5
leader → the follower falls back to the status-poll path (no behavior change).
- **Leader** (`election_heartbeat`): stamps `leader_last_seq` on every heartbeat.
- **Follower** (`election_driver::on_heartbeat` → `node::note_leader_frontier_for_readiness`):
on every ACCEPTED heartbeat, folds `leader_last_seq` into the lag gauge
(monotonic — keeps the `lag_segments` metric/`local_status` truthful on idle for
every follower) and, for a joiner that is not yet converged, computes
`lag = leader_last_seq applied_seqno(leader_shard)` and drives the existing
`note_lag_for_readiness`. The convergence uses a **real** leader frontier (never
the uninitialized-0 gauge), so a still-behind joiner stays `503` until it
actually catches up.
- **Leader-elect edge** (`become_leader_for_term`): a joiner that WINS leadership
receives no heartbeats, so it converges its latch on activation — by
construction it is caught up to its own log. Without this a promoted-then-elected
joiner would stay `503` forever on an idle cluster.
Net: a caught-up joiner converges within a heartbeat interval (~100ms here) of
catching up, with no write traffic and no status poll.
## 3. The cert SAN scale gap
`k8s/cluster/certs.yaml` (and `scripts/gen-cluster-certs.sh`) enumerated SANs for
`tidaldb-0/1/2` only. Scaling the StatefulSet to 5 (T4) gives `tidaldb-3/4` no
matching SAN, so the inter-node mTLS handshake to the new pods fails.
Fix: a **wildcard pod SAN** `*.tidaldb-peers.tidaldb-cluster.svc.cluster.local`
covers every ordinal, so scale-up/down needs no cert re-issue. The explicit
`tidaldb-0/1/2` names and the headless/client Service names are retained
(belt-and-suspenders for any strict verifier). tidalDB dials peers over
tonic → rustls → webpki, which matches a wildcard against the single leftmost DNS
label per RFC 6125 — exactly the pod-ordinal label.
Verified for real (openssl leaf generated by the updated script):
```
$ openssl verify -CAfile ca.crt \
-verify_hostname tidaldb-7.tidaldb-peers.tidaldb-cluster.svc.cluster.local tls.crt
tls.crt: OK # tidaldb-7 is NOT explicitly listed — matched by the wildcard
```
## 4. Verification
### Idle-readiness regression test (real OS processes, tier-3)
`tidal-server/tests/cluster_membership.rs::mp_idle_cluster_snapshot_joiner_flips_ready_without_traffic`:
1. 3-node elected cluster; heavy seed → graceful leader restart → WAL compaction
past seq 1, so the later joiner takes the **snapshot-install** path
(`install_boot`/`seed_joiner` true → readiness IS gated on `converged`). A
small `needed=false` join boots a voter and never engages the gate, so it
cannot reproduce the stall — this setup is load-bearing.
2. Go **fully idle**, then `add_node` (which returns on `/health/startup`, an
unconditional 200 — NOT on cluster-readiness).
3. **Gate**: poll ONLY the joiner's `/health` (never `/cluster/status/local`,
which would drive the old latch and mask the bug). With zero writes the joiner
flips ready in **<101ms** (one measured run: 257µs; another: 101ms).
4. **Honesty**: head/tail items are then searchable on the joiner `converged
caught up`.
**Negative control (proves it is a real gate, not wiring).** With the
heartbeat-convergence call (`note_leader_frontier_for_readiness`) commented out,
the identical test 503s for the full 30s convergence budget and fails exactly
the pre-m12p5 stall. Re-enabling the call makes it pass in <101ms.
### Suites green (2026-06-14, local)
- `cluster_membership` (tier-3, real processes): **6/6** includes the new idle
test, `mp_seed_join_snapshot_catchup`, and the T4 `mp_scale_3_5_3_under_load_zero_loss`.
- `tidal-net` unit/integration (proto roundtrip + election RPC incl. the new
`leader_last_seq` on the wire): green.
- `tidaldb` lib: 1903 passed. `clippy -D warnings` clean across `tidaldb`,
`tidal-net`, `tidal-server` (incl. `--features cluster-e2e --all-targets`).
## 5. What remains — T4 at 1M/1536-dim on k3s
The exit gate's full form ("joiner reaches lag=0 5 min on a 1M/1536D corpus;
p99 impact < 2× baseline for < 60s; zero loss") requires the production-shape
corpus, which needs multi-node k3s/Ref-A. This is the SAME standing dependency
called out for m12p1-p4: the local kubeconfig cannot reach Ref-A. What is proven
locally:
- the 353 scale-up machinery, zero acked loss, quorum on the grown set
(`mp_scale_3_5_3_under_load_zero_loss`);
- the idle-readiness fix that lets an idle scale-up actually join the VIP the
specific blocker that would have stalled a real k3s scale-up.
When Ref-A is reachable: deploy `k8s/cluster/` (now wildcard-SAN), seed 1M×1536-D,
`kubectl scale statefulset tidaldb --replicas=5` under the tidal-stress load,
record joiner-lag-to-0 and the p99 envelope, then scale back to 3.
## 6. Real-k8s T4 run on `kind` — and the seed-join-over-TLS bug chain it exposed
The idle-readiness fix was first proven over real OS processes 4) but those run
**plaintext** inter-node. Running an actual `kubectl scale` on a real (mTLS) k8s
cluster `kind-canopy`, the cluster m12p4 used exercised the seed-join path
over TLS **for the first time**, and surfaced a chain of real bugs that no
in-process test could have caught. All are fixed; the run is GREEN.
Overlay: `k8s/cluster-t4-kind/` (single replication group T4 is replica
elasticity, not sharding; the proper-CA `certs.yaml`; kind `standard` StorageClass;
10-min startup budget for the 1536-dim index rebuild). Seed/load Jobs:
`tidal-stress/k8s/t4-{seed,load}-job.yaml`.
### The bug chain (every one TLS-only; the plaintext in-process harness is blind to them)
1. **Seed dial scheme** (`statefulset.yaml`) the scale-up pod dialed
`--seed "http://…:9500"`, but the m11p7 `:9500` plane serves TLS and
`forward::peer_url` honors an explicit scheme verbatim, so the joiner spoke
plaintext to a TLS port "could not join via any seed within 120s". Fixed to
`https://`.
2. **rustls `CryptoProvider` install order** (`tidal-server/main.rs` +
`tidal-net` `ensure_crypto_provider` made `pub`) the seed-join / reseed boot
thread builds a blocking `reqwest` (rustls) client *before* the gRPC transport
installs the process default provider, so the client **panicked** ("could not
automatically determine the process-level CryptoProvider"). Now installed at
the top of `main()`.
3. **Discovery target** (`statefulset.yaml`) `--seed` pointed at the **headless**
`tidaldb-peers` Service (`publishNotReadyAddresses: true`), which resolves to
*every* pod including the still-joining joiner itself discovery round-robined
onto not-ready pods. Re-pointed at the **ready-only** client Service `tidaldb`.
4. **Discovery poll timeout** (`join_boot.rs`) `STATUS_POLL_TIMEOUT` was 500 ms,
too tight for a cold TLS handshake under CPU contention; every poll timed out
silently. Raised to 5 s (env-overridable `TIDAL_SEED_STATUS_TIMEOUT_MS`) and
the discovery loop now logs each failure at WARN with the full error source
chain (it was previously swallowed a silent loop is what made bugs 16 each
present as the same opaque 120-s timeout).
5. **cert-manager PKI shape** (`certs.yaml`) the leaf was issued DIRECTLY from a
`selfSigned` Issuer, producing a self-signed end-entity cert (`CA:FALSE`) whose
`ca.crt` is a copy of the leaf. Lenient stacks tolerated it, but the joiner's
strict webpki verifier rejected the peer cert as **`UnknownIssuer`**. Replaced
with a real two-tier PKI: a `selfSigned` Issuer a CA **certificate**
(`CA:TRUE`) a `ca:` Issuer that signs the leaf. (`scripts/gen-cluster-certs.sh`
already did this correctly the two were merely inconsistent.)
6. **`grpc_tls` for a node not in the topology** (`join_boot.rs`) **the actual
`UnknownIssuer` root cause.** `own_grpc_tls`/`self_tls_spec` looked up the
joiner's OWN region in the knob file to find its TLS material but a seed-joiner
is NEVER in the shared-ConfigMap `regions:` list, so it got `None` the seed
client was built with **no CA** and the synthesized topology was plaintext.
Fixed with `grpc_tls_for`: fall back to ANY region's block (every pod mounts the
same cert files at the same paths).
### Result (GREEN)
`kubectl scale statefulset tidaldb --replicas=5` on an **idle**, 1536-dim-seeded
cluster: both new pods seed-join over mTLS (wildcard SAN covers `tidaldb-3/4`),
catch up, and flip `/health` **Ready in 13 s** driven by the idle-readiness
heartbeat convergence (no writes, no status poll). Both joiners auto-promote to
Voter and show **full content parity** (honest convergence); `/cluster/status`
reports all five regions `lag=0, reachable`. Pre-m12p5 this would have stalled at
503 indefinitely (the 11.5-h WORKLOG hang).
Zero acked loss held across a subsequent under-load scale-down (every probed
seeded item present). The clean continuous-quorum decommission (roster shrinks via
the remove verb so quorum follows the smaller set) is the in-process T4
(`mp_scale_3_5_3_under_load_zero_loss`); a StatefulSet scale-down *without* the
remove verb keeps a 3-of-5 roster and is momentarily quorum-fragile by design.
### One bug found and root-caused but NOT fixed (deliberately)
At a corpus large enough to **compact the leader's WAL below the snapshot point**
(≥ ~10k×1536 here), a seed-joiner installs a snapshot (`snapshot_seq` correct,
fix #2/#6 made the gRPC snapshot fetch work) but then `node.rs:734` seeds the
post-install catch-up frontier from `db.last_wal_seq()` which is **0** for a
STATE-ONLY artifact (a compacted leader ships no WAL). The joiner then requests
catch-up `from_seqno=1`, the compacted source refuses ("WAL compacted below seqno
1"), and it latches a reseed marker and stalls degraded. The sentinel already
carries the correct `snapshot_seq`, but a naïve "use `snapshot_seq`" fix **broke
`mp_seed_join_snapshot_catchup`** the frontier is own-WAL numbering with a
`stream_baseline` translation, not stream numbering, so the real fix lives in the
own-WALstream mapping for a state-only install. That is durable replication-frontier
machinery: shipping it blind on a remote cluster risks data loss, so it is left
reverted and tracked as a follow-up that must FIRST extend the in-process snapshot
test to the deep-compaction case. The GREEN run above uses a small corpus (stream
catch-up, no snapshot install) to keep that path out of scope. The full 1M/1536-dim
gate remains the standing Ref-A/k3s dependency.