tidaldb/tidal-server/tests/cluster_cross_shard_reads.rs
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

265 lines
11 KiB
Rust

//! Tier-3 cross-shard unified reads suite (m12p4, the m11p6 L4 follow-up; REAL
//! multi-process cluster with PARTIAL shard placement).
//!
//! The m11p6 read fan-out (`scatter_merge`) is corpus-complete ONLY when a node
//! hosts a replica of EVERY shard group (full placement / `S=1`). Under PARTIAL
//! placement a node misses the groups it does not host, so a `/feed` on it would
//! be local-shard-only. m12p4 closes that: the gateway runs its LOCAL scatter,
//! then fans out to the groups it does NOT host (`forward_candidates`, internal
//! `?shard=g` hop) and merges. Two pillars:
//!
//! 1. **Cross-node fan-out makes a partial-node read corpus-complete** — items
//! written across all groups are ALL returned by a `/feed` served from a node
//! that hosts a strict subset of those groups (it reaches the missing groups
//! over HTTP). The same `/feed` on a node hosting a different subset returns
//! the SAME corpus — coverage is placement-independent.
//!
//! 2. **An unreachable missing group degrades, never hard-fails** — kill the
//! sole node hosting a group, and a partial node's `/feed` still returns the
//! items from the reachable groups (a partial, non-empty page) instead of a
//! 5xx — the honest-degraded contract.
//!
//! Run: `cargo test -p tidal-server --features cluster-e2e --test cluster_cross_shard_reads -- --nocapture`
#![cfg(feature = "cluster-e2e")]
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::too_many_lines
)]
mod support;
use std::collections::BTreeSet;
use std::time::Duration;
use support::multiproc::{MultiProcCluster, convergence_budget};
use tidaldb::{replication::shard::ShardRouter, schema::EntityId};
/// Fast election so a single-replica group self-elects promptly and a killed
/// node's failover (pillar 2's reachable groups) completes inside the budget.
const FAST_ELECTION_YAML: &str = "election:\n heartbeat_interval_ms: 100\n election_timeout_min_ms: 500\n election_timeout_max_ms: 1000\n leader_lease_ms: 350";
const NODES: usize = 3;
const SHARDS: usize = 3;
const ITEMS: u64 = 60;
/// Partial placement (m12p4): a "ring" where each group has RF=2 (so the engine
/// builds its WAL ship feed — a single-replica group has no peer and is rejected)
/// but each NODE hosts a STRICT SUBSET (2 of the 3 groups). Every node therefore
/// misses exactly one group and MUST cross-shard fan out to be corpus-complete.
///
/// * group 0: nodes [0, 1] (leader 0) — node 2 misses it
/// * group 1: nodes [1, 2] (leader 1) — node 0 misses it
/// * group 2: nodes [2, 0] (leader 2) — node 1 misses it
///
/// So node 0 hosts {0, 2} and misses group 1; node 1 hosts {0, 1} and misses
/// group 2; node 2 hosts {1, 2} and misses group 0.
fn partial_placement() -> Vec<Vec<usize>> {
vec![vec![0, 1], vec![1, 2], vec![2, 0]]
}
/// Write `1..=ITEMS` items (each with a `view` signal so it ranks in `for_you`)
/// through `gateway`'s `/items` + `/signals` — the gateway hash-routes each to
/// its owning group's leader (forwarding when the gateway does not host it). All
/// items land on SOME group; the set spans all `SHARDS` groups by the router hash.
fn seed_corpus(cluster: &MultiProcCluster, gateway: usize) -> BTreeSet<u64> {
let mut written = BTreeSet::new();
for e in 1..=ITEMS {
let item = cluster.post(
gateway,
"/items",
&serde_json::json!({ "entity_id": e, "metadata": { "title": format!("item {e}") } }),
);
assert!(
item.status().is_success(),
"POST /items for entity {e} should route + apply (status {})",
item.status()
);
let sig = cluster.post(
gateway,
"/signals",
&serde_json::json!({ "entity_id": e, "signal": "view", "weight": 1.0 }),
);
assert!(
sig.status().is_success(),
"POST /signals for entity {e} should route + apply (status {})",
sig.status()
);
written.insert(e);
}
written
}
/// The set of entity ids a `/feed` on `gateway` returned (best-effort: returns
/// the parsed item `entity_id`s; panics if the read itself failed).
fn feed_entities(cluster: &MultiProcCluster, gateway: usize, limit: u32) -> BTreeSet<u64> {
let body = cluster.get_json(gateway, &format!("/feed?profile=for_you&limit={limit}"));
body["items"]
.as_array()
.unwrap_or(&Vec::new())
.iter()
.filter_map(|it| it["entity_id"].as_u64())
.collect()
}
/// Which groups the written corpus actually spans (by the gateway router hash) —
/// the test only asserts cross-shard completeness if the corpus genuinely touches
/// a group some partial node does not host.
fn groups_touched(written: &BTreeSet<u64>) -> BTreeSet<u16> {
let router = ShardRouter::hash(SHARDS as u16).expect("build shard router");
written
.iter()
.map(|&e| router.route(EntityId::new(e)).0)
.collect()
}
/// Pillar 1: a `/feed` served from a node hosting a STRICT SUBSET of the groups
/// returns items spanning ALL groups (the cross-node fan-out), and the same read
/// on a differently-placed node returns the SAME corpus.
#[test]
fn mp_partial_placement_feed_spans_all_groups() {
let cluster = MultiProcCluster::start_sharded_partial(
NODES,
&partial_placement(),
Some(FAST_ELECTION_YAML),
);
let _ = cluster.wait_shard_leaders_agreed_partial(convergence_budget());
// Seed the whole corpus through node 0's gateway (it forwards each write to
// the owning group's leader — including group 1, which node 0 does not host).
let written = seed_corpus(&cluster, 0);
let touched = groups_touched(&written);
assert_eq!(
touched,
(0..SHARDS as u16).collect(),
"test corpus must touch EVERY group so each partial node misses real data — got {touched:?}"
);
// Let the cross-group write forwards + signal applies settle on every leader.
std::thread::sleep(Duration::from_millis(800));
let router = ShardRouter::hash(SHARDS as u16).expect("build shard router");
// Node 0 hosts {0, 2} and MISSES group 1 — its group-1 items are reachable
// ONLY by the cross-shard fan-out to a node hosting group 1.
let on_node0 = feed_entities(&cluster, 0, ITEMS as u32);
let group1_items: BTreeSet<u64> = written
.iter()
.copied()
.filter(|&e| router.route(EntityId::new(e)).0 == 1)
.collect();
assert!(
!group1_items.is_empty(),
"precondition: some written items hash to group 1 (node 0's missing group)"
);
let returned_group1: BTreeSet<u64> = on_node0.intersection(&group1_items).copied().collect();
assert_eq!(
returned_group1, group1_items,
"node 0's /feed must return EVERY group-1 item via the cross-shard fan-out \
(missing items ⇒ the read was local-shard-only): returned {returned_group1:?} of {group1_items:?}"
);
// The full corpus is covered (the page holds them all).
assert_eq!(
on_node0, written,
"node 0's cross-shard /feed must cover the WHOLE corpus"
);
// Placement-independence: node 1 hosts {0, 1} and MISSES group 2, yet returns
// the SAME whole corpus (its missing group differs from node 0's).
let on_node1 = feed_entities(&cluster, 1, ITEMS as u32);
assert_eq!(
on_node1, written,
"node 1's cross-shard /feed (different missing group) must cover the same whole corpus"
);
}
/// Pillar 2: killing the sole node hosting a group degrades a partial node's
/// `/feed` to a partial (non-empty) page — never a hard failure.
#[test]
fn mp_partial_placement_feed_degrades_when_group_unreachable() {
let mut cluster = MultiProcCluster::start_sharded_partial(
NODES,
&partial_placement(),
Some(FAST_ELECTION_YAML),
);
let _ = cluster.wait_shard_leaders_agreed_partial(convergence_budget());
let written = seed_corpus(&cluster, 0);
std::thread::sleep(Duration::from_millis(800));
// Sanity: before the kill, node 0's cross-shard feed covers the whole corpus.
let before = feed_entities(&cluster, 0, ITEMS as u32);
assert_eq!(before, written, "pre-kill /feed must be corpus-complete");
// Group 1 lives on nodes {1, 2}; node 0 hosts groups {0, 2}. Kill BOTH of
// group 1's replicas → group 1 is unreachable from node 0's fan-out, while
// node 0's LOCAL replicas of groups 0 and 2 still serve reads (a read needs no
// leader). Node 0 is the lone survivor for groups 0 and 2.
cluster.kill_hard(1);
cluster.kill_hard(2);
// Let the connect-fail / breaker surface (the fan-out hop to group 1 errors).
std::thread::sleep(Duration::from_secs(2));
// The degraded read MUST still succeed (HTTP 200) and return the reachable
// groups' items — never a 5xx, never an empty page.
let resp = cluster.get(0, &format!("/feed?profile=for_you&limit={ITEMS}"));
assert!(
resp.status().is_success(),
"a missing-group outage must DEGRADE the read, not fail it (status {})",
resp.status()
);
let body: serde_json::Value = resp.json().expect("feed body is JSON");
let returned: BTreeSet<u64> = body["items"]
.as_array()
.expect("items array")
.iter()
.filter_map(|it| it["entity_id"].as_u64())
.collect();
let router = ShardRouter::hash(SHARDS as u16).expect("build shard router");
// Groups 0 and 2 are hosted locally on node 0 → reachable. Group 1 → gone.
let reachable: BTreeSet<u64> = written
.iter()
.copied()
.filter(|&e| {
let g = router.route(EntityId::new(e)).0;
g == 0 || g == 2
})
.collect();
let group1: BTreeSet<u64> = written
.iter()
.copied()
.filter(|&e| router.route(EntityId::new(e)).0 == 1)
.collect();
assert!(
!returned.is_empty(),
"degraded /feed must still return the reachable groups' items, not an empty page"
);
// Every reachable-group item is still present (node 0's local scatter is fine).
assert!(
reachable.is_subset(&returned),
"degraded /feed must still cover groups 0+2 (locally hosted): missing {:?}",
reachable.difference(&returned).collect::<Vec<_>>()
);
// The unreachable group's items are gone — degraded, not magically present.
assert!(
returned.is_disjoint(&group1),
"group 1 is unreachable (both replicas killed), so its items cannot appear"
);
// m12p4 review fix (CRITICAL): the degradation must be VISIBLE on the wire,
// not server-log-only — the client must be able to tell a partial page from a
// complete one. A degraded read carries a non-empty `unavailable_shards`.
let unavailable = body["unavailable_shards"]
.as_array()
.expect("degraded /feed must surface `unavailable_shards` on the wire (not silent)");
assert!(
!unavailable.is_empty(),
"a degraded read must NAME the unreachable group(s) so the client knows the page is partial"
);
}