tidaldb/docs/profiling/m12p5-idle-readiness-elasticity.md
jx12n aa94fd9b1f feat(m12p5): idle-readiness convergence via heartbeat live frontier + wildcard cert SAN
Leader heartbeat now carries its live flushed WAL frontier (leader_last_seq,
proto field 14) so a snapshot-installed joiner converges its sticky readiness
latch from the heartbeat — which flows even on a fully idle cluster — instead of
only from observed ship traffic or an external status poll. Fixes the
idle-readiness stall (WORKLOG 2026-06-13: an 11.5h /health 503 hang where a
caught-up joiner never joined the Service VIP).

- proto: HeartbeatRequest.leader_last_seq (field 14); 0 = pre-m12p5 leader → fall
  back to the status-poll readiness path
- ElectionHooks::on_heartbeat threads leader_last_seq through net + driver
- ShardReplica::note_leader_frontier_for_readiness folds the frontier into the
  lag gauge (monotonic per shard) and drives the readiness latch using a REAL
  leader frontier (never the uninitialized-0 gauge, which would false-converge a
  still-behind joiner); a joiner that WINS leadership converges trivially
- tier-3 regression: mp_idle_cluster_snapshot_joiner_flips_ready_without_traffic
  — snapshot joiner flips /health ready on an idle cluster with zero writes and
  no status poll, then proves content parity (honest convergence)
- certs: wildcard pod SAN (*.tidaldb-peers...) in k8s/cluster/certs.yaml and
  scripts/gen-cluster-certs.sh so StatefulSet scale-up/down with --seed needs no
  cert re-issue (T4 scale-to-5 broke mTLS on tidaldb-3/4); explicit per-pod
  names kept as belt-and-suspenders
- docs/profiling/m12p5-idle-readiness-elasticity.md: root-cause + fix writeup
2026-06-14 16:21:00 -06:00

7.1 KiB
Raw Blame History

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 (/healthregion_healthis_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_heartbeatnode::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 3→5→3 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.