tidaldb/tidal-stress/src/client.rs
jx12n 727fbfcb6b fix(m12p6): 6-bug k3s 3-shard cluster repair (rc8+rc9)
Root-caused and fixed five sharding bugs exposed on the real k3s 3-shard
cluster (rc5→rc7), plus a divergent-rejoin reseed loop found in rc9:

1. reseed shard-awareness (Bug 3, keystone): `run_boot_install_for_region`
   visits each hosted group's own shard subdir; per-group leader discovery
   appends `?shard=N` so a divergent shard heals from its own leader (not
   shard-0's WAL/term — cross-shard contamination).
2. leader self-join term (Bug 4): `become_leader_for_term` now calls
   `note_self_won_term` so the elected shard's `joined_term` is set and
   `cluster_promote` routes rebalances correctly (was: topology-era mis-read
   → legacy fenced promote → 500).
3. boot self-heal self-pull guard (Bug 2): `leader_shard != my_shard` gate
   prevents a node pulling its own stream (its stream isn't a registered peer)
   → eliminates the `PeerUnreachable(self)` loop.
4. scatter-merge degraded partial (Bug 1): failed shard logs + continues
   instead of `?`-failing the whole read; bounded read-admission semaphore
   (`offload.rs`) sheds as 429 instead of piling into a 36s p99.
5. WAL retention (Bug 5): `compact_wal_retained` keeps `WAL_RETENTION_SEGMENTS=4`
   most-recent sealed segments; online path gets the same retention clamp.
   Prevents brief-restart forced-reseed.
6. divergent-rejoin reseed loop (Bug 6, rc9): `note_quarantined` latches
   `from_seqno = stream_baseline` (not `frontier + 1`) so `wal_covers`
   returns `needed=true` and the snapshot installs instead of looping.

Also: `TidalDb::close_shared` for deterministic HNSW save on cluster SIGTERM
(HNSW graph was not saved when request-scoped Arc clones were alive at shutdown);
updated profiling doc with full rc8/rc9 fix narrative; k8s recall job YAMLs.
2026-06-16 22:34:21 -06:00

476 lines
18 KiB
Rust
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.

//! Thin async HTTP client over the tidalDB surface, plus the corpus seeder.
//!
//! The client returns a [`StatusClass`] per request and nothing else — latency is
//! measured by the caller from the request's *intended* send time so the figure
//! is coordinated-omission-corrected (a slow server inflates the number it would
//! otherwise hide). Response bodies are drained so the keep-alive connection is
//! reused (and so the measured time includes full response transfer).
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use rand::Rng;
use reqwest::header::{AUTHORIZATION, HeaderValue};
use serde::{Deserialize, Serialize};
use tokio::sync::Semaphore;
use crate::error::{Result, StressError};
use crate::metrics::StatusClass;
use crate::workload::{Body, HttpMethod, ItemMetadata, Plan};
/// `POST /vector_search` request body for the recall probe — serialized straight
/// to the wire (borrows the query vector; no owned copy).
#[derive(Serialize)]
struct VectorQuery<'a> {
vector: &'a [f32],
k: usize,
#[serde(skip_serializing_if = "Option::is_none")]
ef_search: Option<usize>,
}
/// A `POST /signals` body carrying `user_id`, used to BUILD a user's preference
/// vector (a positive-engagement `like` on an embedded item). The standalone
/// surface reads `user_id`; the regular load `Body::Signal` omits it.
#[derive(Serialize)]
struct PreferenceLike {
entity_id: u64,
signal: &'static str,
weight: f64,
user_id: u64,
}
/// The parts of the `/vector_search` response the recall oracle reads: the ranked
/// entity IDs (distances are ignored — recall@k is a set-overlap metric).
#[derive(Deserialize)]
struct VectorSearchResponseBody {
items: Vec<VectorSearchResponseItem>,
}
#[derive(Deserialize)]
struct VectorSearchResponseItem {
entity_id: u64,
}
/// Async client with optional bearer auth. Cheap to clone (Arc inside reqwest).
#[derive(Clone)]
pub struct HttpClient {
inner: reqwest::Client,
/// The `Authorization: Bearer <key>` header value, built ONCE at construction
/// (the token never changes) so the per-request path is a cheap refcounted
/// clone instead of `format!` + header re-validation on every send. `None` when
/// no key is configured. Marked sensitive so it is redacted from any debug output.
auth_header: Option<HeaderValue>,
/// `x-tidal-ack` value sent with every write (m11p3: `leader`/`quorum`);
/// `None` = the deployment's topology default.
ack: Option<String>,
}
impl HttpClient {
/// `request_timeout` should sit ABOVE the server's 30s request timeout so the
/// server's own 408 surfaces as a Timeout class rather than a client abort.
///
/// # Errors
///
/// Returns [`StressError::Client`] if the underlying reqwest client cannot be
/// built (TLS/builder fault), and [`StressError::Auth`] if `api_key` contains
/// characters that are not valid in an HTTP `Authorization` header value.
pub fn new(
request_timeout: Duration,
api_key: Option<String>,
ack: Option<String>,
ca_cert: Option<String>,
insecure: bool,
) -> Result<Self> {
let mut builder = reqwest::Client::builder()
.timeout(request_timeout)
.connect_timeout(Duration::from_secs(5))
// Warm-connection pool sized to comfortably saturate the server's
// 100-concurrency limit per host without exhausting container fds at
// 4 host-pools (≈4×256 idle ceiling).
.pool_max_idle_per_host(256)
.pool_idle_timeout(Duration::from_secs(90))
.tcp_nodelay(true);
// m11p7: the cluster's :9500 plane serves TLS from a private CA. Trust it
// via the mounted ca.crt (verified), or skip verification with --insecure.
if let Some(path) = ca_cert {
let pem = std::fs::read(&path).map_err(|source| StressError::CaCert {
path: path.clone(),
source,
})?;
let cert = reqwest::Certificate::from_pem(&pem).map_err(StressError::Client)?;
builder = builder.add_root_certificate(cert);
}
if insecure {
builder = builder.danger_accept_invalid_certs(true);
}
let inner = builder.build().map_err(StressError::Client)?;
// Build the Authorization header once. reqwest's `bearer_auth` does a fresh
// `format!("Bearer {token}")` allocation plus a HeaderValue validation pass
// on EVERY call, over a token that never changes — pure per-request waste.
let auth_header = match api_key {
Some(k) => {
let mut v = HeaderValue::from_str(&format!("Bearer {k}"))
.map_err(|_| StressError::Auth("contains invalid header characters".into()))?;
v.set_sensitive(true);
Some(v)
}
None => None,
};
Ok(Self {
inner,
auth_header,
ack,
})
}
fn apply_auth(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
// A HeaderValue clone is a cheap refcount bump (or inline copy), not a
// realloc+revalidate. `cluster_status` deliberately does NOT call this, so
// its status poll stays unauthenticated as before.
match &self.auth_header {
Some(v) => rb.header(AUTHORIZATION, v.clone()),
None => rb,
}
}
/// Issue one request and classify the result. Never returns an error — a
/// transport fault is a [`StatusClass::Transport`] outcome, not a fatal.
pub async fn execute(&self, plan: &Plan) -> StatusClass {
let rb = match plan.method {
HttpMethod::Get => self.inner.get(&plan.url),
HttpMethod::Post => {
let mut rb = self.inner.post(&plan.url);
if let Some(ack) = &self.ack {
rb = rb.header("x-tidal-ack", ack);
}
match &plan.body {
Some(b) => rb.json(b),
None => rb,
}
}
};
match self.apply_auth(rb).send().await {
Ok(resp) => {
let class = StatusClass::from_status(resp.status().as_u16());
// Drain the body so the connection returns to the pool.
let _ = resp.bytes().await;
class
}
Err(_) => StatusClass::Transport,
}
}
/// Issue a `POST {base}/vector_search` recall probe (m12p1) and return both
/// the capacity [`StatusClass`] and, on a 2xx, the returned entity IDs in
/// rank order (closest-first). The IDs are what the recall oracle compares
/// against its brute-force ground truth; a non-2xx or a body it cannot parse
/// yields `None` for the IDs (the status still classifies the outcome).
///
/// Latency is measured by the caller around this call (from the request's
/// intended send time), preserving the coordinated-omission correction.
pub async fn vector_search_ids(
&self,
base: &str,
vector: &[f32],
k: usize,
ef_search: Option<usize>,
) -> (StatusClass, Option<Vec<u64>>) {
let url = format!("{base}/vector_search");
let body = VectorQuery {
vector,
k,
ef_search,
};
let rb = self.inner.post(&url).json(&body);
match self.apply_auth(rb).send().await {
Ok(resp) => {
let class = StatusClass::from_status(resp.status().as_u16());
if class != StatusClass::Ok {
// Drain so the connection returns to the pool.
let _ = resp.bytes().await;
return (class, None);
}
// The recall oracle needs the ranked IDs; parse them out. A parse
// failure on a 2xx is reported as Ok-without-ids (a recall miss is
// not a transport error) so the latency sample is still honest.
let ids = resp
.json::<VectorSearchResponseBody>()
.await
.ok()
.map(|b| b.items.into_iter().map(|i| i.entity_id).collect());
(class, ids)
}
Err(_) => (StatusClass::Transport, None),
}
}
/// Fetch `GET {base}/cluster/status` (unauthenticated) and return the leader
/// name and the worst replication lag across regions — used to watch whether
/// follower lag grows under write load between ramp stages. `None` on any fault.
pub async fn cluster_status(&self, base: &str) -> Option<(String, i64)> {
let url = format!("{base}/cluster/status");
let resp = self.inner.get(&url).send().await.ok()?;
let v: serde_json::Value = resp.json().await.ok()?;
let leader = v.get("leader")?.as_str()?.to_string();
let max_lag = v
.get("regions")?
.as_array()?
.iter()
.filter_map(|r| r.get("lag_events").and_then(serde_json::Value::as_i64))
.max()
.unwrap_or(0);
Some((leader, max_lag))
}
/// POST a preference-building `like` (with `user_id`), retrying 429 like the
/// corpus seeder. Returns whether it ultimately succeeded.
async fn post_preference(&self, url: &str, body: &PreferenceLike) -> bool {
const MAX_ATTEMPTS: u32 = 8;
for attempt in 0..MAX_ATTEMPTS {
let rb = self.inner.post(url).json(body);
match self.apply_auth(rb).send().await {
Ok(resp) => {
let code = resp.status().as_u16();
let _ = resp.bytes().await;
if (200..=299).contains(&code) {
return true;
}
if code == 429 {
tokio::time::sleep(Duration::from_millis(40 + u64::from(attempt) * 20))
.await;
continue;
}
if code == 503 {
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
return false;
}
Err(_) => return false,
}
}
false
}
/// One-off POST returning the raw status, for the seeder (which needs to know
/// success vs 429-retry rather than a capacity class).
async fn post_json(&self, url: &str, body: &Body) -> Option<u16> {
let rb = self.inner.post(url).json(body);
match self.apply_auth(rb).send().await {
Ok(resp) => {
let code = resp.status().as_u16();
let _ = resp.bytes().await;
Some(code)
}
Err(_) => None,
}
}
}
/// Register `count` items (id 1..=count) plus a `dim`-wide content embedding for
/// each, against `base`, using RANDOM embedding values.
///
/// Use the LEADER url so items broadcast to every region and `/feed` on any
/// region can rank them. Bounded-concurrency, retries 429. Returns the number of
/// items confirmed registered.
///
/// The ramp mode seeds random embeddings because their exact values are
/// irrelevant to the write-path cost it measures. The recall mode instead calls
/// [`seed_corpus_with`] with a DETERMINISTIC id-keyed generator so the
/// generator's brute-force ground truth matches what the engine indexed.
///
/// # Errors
///
/// Returns [`StressError::Seed`] if the bounded-concurrency semaphore is closed
/// while acquiring a permit (the run cannot proceed).
pub async fn seed_corpus(
client: &HttpClient,
base: &str,
count: u64,
dim: usize,
concurrency: usize,
) -> Result<u64> {
seed_corpus_with(client, base, count, concurrency, move |_id| {
// A fresh ThreadRng per call (it is !Send, so it cannot cross the await
// inside the task; building the Vec up front keeps it off the await path).
let mut rng = rand::rng();
(0..dim).map(|_| rng.random::<f32>() - 0.5).collect()
})
.await
}
/// Register `count` items (id 1..=count) plus a content embedding for each, where
/// each item's embedding values come from `embedding(id)`.
///
/// This is the seam the recall harness uses: passing a deterministic id-keyed
/// generator makes the seeded corpus exactly reproducible, so the in-RAM
/// brute-force ground truth the oracle computes is bit-for-bit the corpus the
/// engine indexed. Same bounded-concurrency + 429-retry contract as
/// [`seed_corpus`].
///
/// # Errors
///
/// Returns [`StressError::Seed`] if the bounded-concurrency semaphore is closed
/// while acquiring a permit.
pub async fn seed_corpus_with<F>(
client: &HttpClient,
base: &str,
count: u64,
concurrency: usize,
embedding: F,
) -> Result<u64>
where
F: Fn(u64) -> Vec<f32> + Send + Sync + Clone + 'static,
{
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
let done = Arc::new(AtomicU64::new(0));
let items_url = format!("{base}/items");
let emb_url = format!("{base}/embeddings");
let mut handles = Vec::new();
for id in 1..=count {
let permit = sem
.clone()
.acquire_owned()
.await
.map_err(|e| StressError::Seed(format!("semaphore closed: {e}")))?;
let client = client.clone();
let items_url = items_url.clone();
let emb_url = emb_url.clone();
let done = done.clone();
let embedding = embedding.clone();
handles.push(tokio::spawn(async move {
let _permit = permit;
let category = SEED_CATEGORIES[(id as usize) % SEED_CATEGORIES.len()];
let item = Body::Item {
entity_id: id,
metadata: ItemMetadata {
title: format!("post-{id}"),
category,
},
};
let emb = Body::Embedding {
entity_id: id,
values: embedding(id),
};
// The item and embedding writes are independent — the engine keys the
// embedding purely on entity id with no item-before-embedding
// precondition (verified: db/items.rs write_item_embedding) — so issue
// them concurrently rather than serially, roughly halving each id's
// seed wall-clock. `done` still requires BOTH to succeed.
let (item_ok, emb_ok) = tokio::join!(
retry_write(&client, &items_url, &item),
retry_write(&client, &emb_url, &emb),
);
if item_ok && emb_ok {
done.fetch_add(1, Ordering::Relaxed);
}
}));
}
for h in handles {
let _ = h.await;
}
Ok(done.load(Ordering::Relaxed))
}
/// Build a preference vector for each user `1..=users` (m12p2).
///
/// Each user sends one positive `like` signal (carrying `user_id`) on a
/// deterministic embedded item, so the `for_you` profile's ANN candidate
/// generation engages for those users instead of degrading to a scan.
///
/// Each user `u` likes item `((u - 1) % corpus) + 1`, whose embedding becomes (a
/// scaled copy of) the user's preference vector. Bounded concurrency; retries
/// 429. Returns the number of preference likes confirmed.
///
/// # Errors
///
/// Returns [`StressError::Seed`] if the bounded-concurrency semaphore is closed.
pub async fn seed_preferences(
client: &HttpClient,
base: &str,
users: u64,
corpus: u64,
concurrency: usize,
) -> Result<u64> {
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
let done = Arc::new(AtomicU64::new(0));
let url = format!("{base}/signals");
let corpus = corpus.max(1);
let mut handles = Vec::new();
for u in 1..=users {
let permit = sem
.clone()
.acquire_owned()
.await
.map_err(|e| StressError::Seed(format!("semaphore closed: {e}")))?;
let client = client.clone();
let url = url.clone();
let done = done.clone();
handles.push(tokio::spawn(async move {
let _permit = permit;
let item = ((u - 1) % corpus) + 1;
let body = PreferenceLike {
entity_id: item,
signal: "like",
weight: 1.0,
user_id: u,
};
if client.post_preference(&url, &body).await {
done.fetch_add(1, Ordering::Relaxed);
}
}));
}
for h in handles {
let _ = h.await;
}
Ok(done.load(Ordering::Relaxed))
}
const SEED_CATEGORIES: [&str; 12] = [
"anime",
"gaming",
"fitness",
"music",
"cosplay",
"art",
"fantasy",
"scifi",
"romance",
"comedy",
"horror",
"slice-of-life",
];
/// POST with backpressure-aware retry: a 2xx is success; a 429 backs off and
/// retries (the seed must not give up just because the write pool is busy); any
/// other code / transport fault fails fast (a usage bug, not transient load).
async fn retry_write(client: &HttpClient, url: &str, body: &Body) -> bool {
// A bulk SEED must persist through sustained write-pool saturation: when one
// leader owns several shards, a fast multi-writer seed keeps its bounded
// admission pool full and it sheds 429 for seconds at a stretch. The old
// 8-attempt / ~900ms budget gave up inside that window and silently DROPPED
// ~0.4% of the corpus, failing the recall harness's exact-count gate. 429 is
// backpressure, not failure — retry it persistently (exponential backoff
// capped at 500ms → ~12s total budget, comfortably past a saturation burst).
const MAX_ATTEMPTS: u32 = 40;
for attempt in 0..MAX_ATTEMPTS {
match client.post_json(url, body).await {
Some(code) if (200..=299).contains(&code) => return true,
Some(429) => {
let backoff = std::cmp::min(40 + u64::from(attempt) * 40, 500);
tokio::time::sleep(Duration::from_millis(backoff)).await;
}
Some(503) => {
// Leader briefly unreachable (e.g. a roll) — short wait, retry.
tokio::time::sleep(Duration::from_millis(150)).await;
}
_ => return false,
}
}
false
}