Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Fixes the two defects a malformed probe exposed on the live cluster, plus the
coverage gap that let a stale assertion survive the same day it was falsified.
TASK 17 — validate before the WAL append. A 128-dim vector against a 1536-dim
slot was appended to the WAL FIRST, then validated, then answered 500 — so an
already-durable, unapplicable record shipped to both followers, halted both
receivers, and put shard 1 into a quorum-write outage. Validation now runs before
the append and returns 400 via invalid_input; nothing enters the log.
`storage::vector::validate_dimensions` is now the single comparison, replacing an
inline duplicate of the same rule in lifecycle/ops.rs:57-62 — two copies of a
dimension check drift, and the apply-path copy is the one that halts replication
when it disagrees.
The receiver's halt-vs-skip decision is now explicit instead of "halt on
anything". A record whose failure is deterministic and node-independent (schema
width) is skipped, counted on blobs_apply_failed_total and ERROR-logged, so the
frontier advances; a record that could become applicable after a binary upgrade
(unknown batch kind, capability skew) still halts, because skipping那 would
silently drop replicated data. Both branches are proven reachable by tests.
TASK 18 — the reseed latch outlived its discharge. A node hosting 3 shard groups
latched a marker per group but discharged on a single seqno, so two latches meant
permanent 503 on a node whose every shard read lag 0 — it hit all three pods
during the roll and each needed a manual delete. Gaps are now tracked per group
in a ReseedGapSet and cleared on evidence about themselves; a REFUSED
reseed_self_restart re-evaluates every 15s instead of waiting for a latch that
never arrives. /health's cause ladder was also lying: it printed "joiner boot not
yet converged" for a node whose groups had all converged, because the fallback
asserted a state it never tested. It now names the outstanding gaps, gained the
decommissioned-by-signal arm that is_ready checked but the ladder did not, and
its terminal arm says "reason unavailable" rather than inventing one.
COVERAGE — 14 of 23 integration suites were run by NO pipeline. Not theoretical:
cluster_routes still asserted the wire fabrication removed hours earlier
(applied_events == 0 with a lag derived from it) and nothing caught it because
nothing ran it. cluster_sharding (dense-rank, /sharded/* opt-in), vector_search
(distance contract) and cluster_poison_embedding (task 17's own gate) were in the
same position, so those guards would have rotted identically. Every suite now has
a runner: 8 in-process ones in a new `fast-suites` push step (measured 71s, runs
FIRST so a cheap failure precedes the 6.5-min gate), 6 multiproc ones in the
nightly. All 23 scheduled; all 4 never-before-run heavy suites verified passing
before being scheduled.
Also fixes cluster_chaos.rs:329, which the nightly's FIRST EVER run caught 13
minutes in — it demanded an unreachable peer report worst-case lag, i.e. it
required the fabrication task 04a deleted.
Verified: fmt clean; clippy 72 vs 73 baseline (one FEWER, zero added, measured on
touched trees at 431340f); lib 2115 passed; all 8 fast suites green;
cluster_chaos 5, cluster_sharding 5, cluster_poison_embedding 1,
cluster_cross_shard_reads 2, cluster_graph_persistence 1, cluster_multiproc 5,
cluster_e2e 2; doc-guard OK.
635 lines
23 KiB
Rust
635 lines
23 KiB
Rust
//! m8p10 task 03: cross-process route tests.
|
|
//!
|
|
//! Builds 3-node in-process clusters — distinct `ShardReplica`s pointing at
|
|
//! each other's REAL loopback gRPC + HTTP addresses — and drives them over real
|
|
//! HTTP (axum on loopback) + real `GrpcTransport` replication + real reqwest
|
|
//! forwarding. No OS processes (tier-3 OS-process coverage is tasks 04/05); these
|
|
//! run in the default test build like `cluster_region.rs` / `cluster_grpc.rs`.
|
|
//!
|
|
//! They prove the node behaves as one coherent cluster: writes to ANY node land
|
|
//! on the leader and replicate; promote propagates; `/cluster/status` aggregates
|
|
//! every region with `reachable` honesty; `/cluster/reconcile` converges both
|
|
//! sides and reports timing (idempotent on repeat); `/sharded/*` fans out across
|
|
//! processes with honest degraded semantics and routes writes to the owner.
|
|
#![allow(
|
|
clippy::unwrap_used,
|
|
clippy::missing_panics_doc,
|
|
clippy::too_many_lines,
|
|
clippy::doc_markdown,
|
|
clippy::cast_precision_loss
|
|
)]
|
|
|
|
use std::{
|
|
net::{SocketAddr, TcpListener},
|
|
sync::Arc,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use tidal_server::cluster::{
|
|
ClusterNode, ElectionSpec, RegionSpec, ReplicationSpec, TimeoutsSpec, TopologySpec, WalSpec,
|
|
build_region_router,
|
|
};
|
|
use tidaldb::schema::{DecaySpec, EntityKind, Schema, SchemaBuilder, Window};
|
|
|
|
/// A `view` signal (decayed) + a `hide` hard-negative signal (so `/hardnegs`
|
|
/// resolves) — the same schema shape `cluster_region.rs` uses.
|
|
fn region_schema() -> Schema {
|
|
let mut builder = SchemaBuilder::new();
|
|
let _ = builder
|
|
.signal(
|
|
"view",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(7 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[Window::OneHour])
|
|
.velocity(false)
|
|
.add();
|
|
let _ = builder
|
|
.signal("hide", EntityKind::Item, DecaySpec::Permanent)
|
|
.velocity(false)
|
|
.add();
|
|
builder.build().unwrap()
|
|
}
|
|
|
|
fn free_addr() -> SocketAddr {
|
|
TcpListener::bind("127.0.0.1:0")
|
|
.unwrap()
|
|
.local_addr()
|
|
.unwrap()
|
|
}
|
|
|
|
/// A 3-region topology where every region declares real loopback grpc + http
|
|
/// addresses pointing at the in-test binds. Region 0 (`us-east`) is the leader.
|
|
struct Cluster3 {
|
|
names: [String; 3],
|
|
grpc: [SocketAddr; 3],
|
|
http: [SocketAddr; 3],
|
|
}
|
|
|
|
impl Cluster3 {
|
|
fn new() -> Self {
|
|
Self {
|
|
names: ["us-east".into(), "eu-west".into(), "ap-south".into()],
|
|
grpc: [free_addr(), free_addr(), free_addr()],
|
|
http: [free_addr(), free_addr(), free_addr()],
|
|
}
|
|
}
|
|
|
|
fn topology(&self) -> TopologySpec {
|
|
TopologySpec {
|
|
regions: (0..3)
|
|
.map(|i| RegionSpec {
|
|
name: self.names[i].clone(),
|
|
grpc_addr: Some(self.grpc[i].to_string()),
|
|
grpc_bind: None,
|
|
http_addr: Some(self.http[i].to_string()),
|
|
grpc_tls: None,
|
|
metrics_addr: None,
|
|
zone: None,
|
|
})
|
|
.collect(),
|
|
leader: self.names[0].clone(),
|
|
write_workers: None,
|
|
timeouts: TimeoutsSpec::default(),
|
|
replication: ReplicationSpec::default(),
|
|
wal: WalSpec::default(),
|
|
election: ElectionSpec::default(),
|
|
shards: None,
|
|
}
|
|
}
|
|
|
|
fn base(&self, i: usize) -> String {
|
|
format!("http://{}", self.http[i])
|
|
}
|
|
}
|
|
|
|
/// A node's persistent data dir (m11p2). MUST be created before — and held
|
|
/// past — anything that can hold the node: a dir deleted while its node runs
|
|
/// wedges fjall's flush worker on `NotFound` and the node's checkpoint thread
|
|
/// polls forever.
|
|
fn region_dir() -> tempfile::TempDir {
|
|
tempfile::tempdir().expect("create per-region data dir")
|
|
}
|
|
|
|
/// Build one region node off the reactor (GrpcTransport::new blocks on its own
|
|
/// runtime, so it must run on a plain thread).
|
|
fn build_region(topology: TopologySpec, region: &str, dir: &tempfile::TempDir) -> ClusterNode {
|
|
let region = region.to_string();
|
|
let data_dir = dir.path().to_path_buf();
|
|
std::thread::spawn(move || {
|
|
ClusterNode::new(
|
|
&topology,
|
|
®ion,
|
|
region_schema(),
|
|
Vec::new(),
|
|
Some(data_dir),
|
|
0,
|
|
)
|
|
})
|
|
.join()
|
|
.unwrap()
|
|
.expect("region node builds with real gRPC transport")
|
|
}
|
|
|
|
/// Serve `router` on `addr` using `rt`; returns once the listener is bound.
|
|
fn serve(rt: &tokio::runtime::Runtime, router: axum::Router, addr: SocketAddr) {
|
|
let listener = rt
|
|
.block_on(tokio::net::TcpListener::bind(addr))
|
|
.unwrap_or_else(|e| panic!("bind {addr}: {e}"));
|
|
rt.spawn(async move {
|
|
let _ = axum::serve(listener, router).await;
|
|
});
|
|
}
|
|
|
|
/// A 3-node serving handle: the runtime, the cluster spec, and an HTTP client.
|
|
struct Serving {
|
|
/// Held for the test's lifetime so the axum servers keep running; dropped at
|
|
/// test end, which stops the runtime and tears the nodes down.
|
|
_rt: tokio::runtime::Runtime,
|
|
/// Per-node data-dir guards (m11p2: persistent WAL). Declared AFTER the
|
|
/// runtime so the nodes (owned by its tasks) drop BEFORE the dirs delete
|
|
/// — a deleted-dir teardown wedges fjall's flush retry loop forever.
|
|
_dirs: Vec<tempfile::TempDir>,
|
|
cluster: Cluster3,
|
|
client: reqwest::blocking::Client,
|
|
}
|
|
|
|
impl Serving {
|
|
/// Build + serve all three region nodes on loopback.
|
|
fn start_all() -> Self {
|
|
Self::start(&[0, 1, 2])
|
|
}
|
|
|
|
/// Build + serve only the named regions (by index), so a test can leave one
|
|
/// node DOWN to exercise unreachable/degraded paths.
|
|
fn start(indices: &[usize]) -> Self {
|
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(3)
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
let cluster = Cluster3::new();
|
|
// Dirs FIRST: they must outlive every node (see `region_dir`).
|
|
let dirs: Vec<tempfile::TempDir> = indices.iter().map(|_| region_dir()).collect();
|
|
for (slot, &i) in indices.iter().enumerate() {
|
|
let node = build_region(cluster.topology(), &cluster.names[i], &dirs[slot]);
|
|
serve(
|
|
&rt,
|
|
build_region_router(
|
|
Arc::new(node),
|
|
std::sync::Arc::new(
|
|
tidal_server::cluster::security::ClusterCreds::unauthenticated(),
|
|
),
|
|
),
|
|
cluster.http[i],
|
|
);
|
|
}
|
|
Self {
|
|
_rt: rt,
|
|
_dirs: dirs,
|
|
cluster,
|
|
client: reqwest::blocking::Client::new(),
|
|
}
|
|
}
|
|
|
|
fn base(&self, i: usize) -> String {
|
|
self.cluster.base(i)
|
|
}
|
|
}
|
|
|
|
/// Poll `pred(json)` against `GET {base}{path}` until it holds or the deadline.
|
|
fn poll_json(
|
|
client: &reqwest::blocking::Client,
|
|
url: &str,
|
|
pred: impl Fn(&serde_json::Value) -> bool,
|
|
) -> serde_json::Value {
|
|
let deadline = Instant::now() + Duration::from_secs(8);
|
|
loop {
|
|
let body: serde_json::Value = client.get(url).send().unwrap().json().unwrap();
|
|
if pred(&body) {
|
|
return body;
|
|
}
|
|
assert!(
|
|
Instant::now() <= deadline,
|
|
"predicate not met within 8s for {url}: {body}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
}
|
|
}
|
|
|
|
fn post_json(
|
|
client: &reqwest::blocking::Client,
|
|
url: &str,
|
|
body: &serde_json::Value,
|
|
) -> reqwest::blocking::Response {
|
|
client.post(url).json(body).send().unwrap()
|
|
}
|
|
|
|
/// POST a `/sharded/*` WRITE with the single-copy opt-in header the surface
|
|
/// requires.
|
|
///
|
|
/// `/sharded/{items,embeddings,signals}` apply to the owning region's local store
|
|
/// with no WAL append, so they are single-copy regardless of the replication
|
|
/// factor and reject (400) a caller who has not said `x-tidal-ack: local`.
|
|
fn post_sharded(
|
|
client: &reqwest::blocking::Client,
|
|
url: &str,
|
|
body: &serde_json::Value,
|
|
) -> reqwest::blocking::Response {
|
|
client
|
|
.post(url)
|
|
.header("x-tidal-ack", "local")
|
|
.json(body)
|
|
.send()
|
|
.unwrap()
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// A signal POSTed to a FOLLOWER is forwarded to the leader (204), durably
|
|
/// applied there, and replicates to every node over gRPC.
|
|
#[test]
|
|
fn forwarded_write_lands_on_leader_and_replicates() {
|
|
let s = Serving::start_all();
|
|
let client = &s.client;
|
|
|
|
// Seed items on the LEADER (m11p2: items ride the replicated log — the
|
|
// kind-1 WAL record reaches every follower; no HTTP broadcast). Then POST
|
|
// signals to a FOLLOWER (eu-west, index 1) — these must forward to the
|
|
// leader.
|
|
for i in 1..=6u64 {
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/items", s.base(0)),
|
|
&serde_json::json!({ "entity_id": i, "metadata": { "title": format!("item {i}") } }),
|
|
);
|
|
assert_eq!(resp.status().as_u16(), 201, "leader /items");
|
|
|
|
// POST /signals to a FOLLOWER → forwarded to leader → 204.
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/signals", s.base(1)),
|
|
&serde_json::json!({ "entity_id": i, "signal": "view", "weight": 1.0 }),
|
|
);
|
|
assert_eq!(
|
|
resp.status().as_u16(),
|
|
204,
|
|
"follower /signals must forward to leader and 204: {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
|
|
// Every follower converges (applied reaches 6, lag 0) via the WAL relay.
|
|
for i in [1usize, 2] {
|
|
poll_json(
|
|
client,
|
|
&format!("{}/cluster/status/local", s.base(i)),
|
|
|st| {
|
|
st["applied_events"].as_u64().unwrap_or(0) >= 6
|
|
&& st["lag_events"].as_u64().unwrap_or(u64::MAX) == 0
|
|
},
|
|
);
|
|
}
|
|
|
|
// The followers' broadcast items are present and the feed ranks them.
|
|
for i in 0..3 {
|
|
let feed: serde_json::Value = client
|
|
.get(format!("{}/feed?profile=trending&limit=6", s.base(i)))
|
|
.send()
|
|
.unwrap()
|
|
.json()
|
|
.unwrap();
|
|
assert!(
|
|
!feed["items"].as_array().unwrap().is_empty(),
|
|
"node {i} feed must rank replicated items"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Promote a NON-leader (eu-west) via that follower; the promote fans out so all
|
|
/// three nodes agree on the new leader, and a write to the OLD leader now forwards
|
|
/// to the new one (transparently 204).
|
|
#[test]
|
|
fn promote_fans_out() {
|
|
let s = Serving::start_all();
|
|
let client = &s.client;
|
|
|
|
// Promote eu-west (index 1) by calling promote ON eu-west; it fans out.
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/cluster/promote", s.base(1)),
|
|
&serde_json::json!({ "region": s.cluster.names[1] }),
|
|
);
|
|
assert_eq!(resp.status().as_u16(), 200);
|
|
let body: serde_json::Value = resp.json().unwrap();
|
|
assert_eq!(body["leader"].as_str(), Some(s.cluster.names[1].as_str()));
|
|
assert!(body.get("acked").is_some(), "promote must report fan-out");
|
|
|
|
// All three nodes agree the leader is eu-west (status/local view).
|
|
for i in 0..3 {
|
|
let st = poll_json(
|
|
client,
|
|
&format!("{}/cluster/status/local", s.base(i)),
|
|
|st| st["leader"].as_str() == Some(s.cluster.names[1].as_str()),
|
|
);
|
|
assert_eq!(st["leader"].as_str(), Some(s.cluster.names[1].as_str()));
|
|
}
|
|
|
|
// A write to the OLD leader (us-east, index 0) now forwards to the new leader
|
|
// (eu-west) and 204s — transparent leader forwarding after a promote.
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/signals", s.base(0)),
|
|
&serde_json::json!({ "entity_id": 1, "signal": "view", "weight": 1.0 }),
|
|
);
|
|
assert_eq!(
|
|
resp.status().as_u16(),
|
|
204,
|
|
"write to demoted node must forward to the new leader: {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
|
|
/// `GET /cluster/status` on any node aggregates EVERY region; with one node DOWN
|
|
/// that region reports `reachable: false`, `partitioned: true`, worst-case lag.
|
|
#[test]
|
|
fn status_aggregates_all_regions() {
|
|
// Start only nodes 0 and 1; ap-south (index 2) is DOWN.
|
|
let s = Serving::start(&[0, 1]);
|
|
let client = &s.client;
|
|
|
|
// Seed a couple of signals so the leader's relay seqno is non-zero.
|
|
for i in 1..=3u64 {
|
|
let _ = post_json(
|
|
client,
|
|
&format!("{}/signals", s.base(0)),
|
|
&serde_json::json!({ "entity_id": i, "signal": "view", "weight": 1.0 }),
|
|
);
|
|
}
|
|
|
|
// Aggregate from the leader: 3 regions, ap-south unreachable.
|
|
let status = poll_json(client, &format!("{}/cluster/status", s.base(0)), |st| {
|
|
let regions = st["regions"].as_array();
|
|
regions.is_some_and(|r| r.len() == 3) && st["relay_log_len"].as_u64().unwrap_or(0) >= 3
|
|
});
|
|
assert_eq!(status["leader"].as_str(), Some(s.cluster.names[0].as_str()));
|
|
let relay_len = status["relay_log_len"].as_u64().unwrap();
|
|
assert!(relay_len >= 3, "leader relay len: {relay_len}");
|
|
|
|
let regions = status["regions"].as_array().unwrap();
|
|
let row = |name: &str| {
|
|
regions
|
|
.iter()
|
|
.find(|r| r["name"].as_str() == Some(name))
|
|
.unwrap()
|
|
};
|
|
|
|
// The leader row: reachable, zero lag.
|
|
let leader_row = row(&s.cluster.names[0]);
|
|
assert_eq!(leader_row["reachable"].as_bool(), Some(true));
|
|
assert_eq!(leader_row["lag_events"].as_u64(), Some(0));
|
|
|
|
// The live follower (eu-west): reachable.
|
|
let follower_row = row(&s.cluster.names[1]);
|
|
assert_eq!(follower_row["reachable"].as_bool(), Some(true));
|
|
|
|
// The DOWN region (ap-south): unreachable, partitioned, applied 0, worst-case
|
|
// lag = leader_last_seq.
|
|
let down_row = row(&s.cluster.names[2]);
|
|
assert_eq!(
|
|
down_row["reachable"].as_bool(),
|
|
Some(false),
|
|
"down region unreachable: {down_row}"
|
|
);
|
|
assert_eq!(down_row["partitioned"].as_bool(), Some(true));
|
|
// CHANGED 2026-08-31, same reason as cluster_chaos.rs:329. This asserted
|
|
// `applied_events == 0` and `lag_events == relay_len` ("worst-case lag =
|
|
// leader_last_seq"). Both were FABRICATED by `aggregate_region_row`'s
|
|
// unreachable arm: it could not reach the peer, so it invented an applied
|
|
// frontier of 0 and derived the deficit from it. A down peer's frontier is
|
|
// genuinely unknown and now says so.
|
|
//
|
|
// Note `relay_len` is no longer compared against: the leader's own frontier tells
|
|
// you nothing about how far a peer you cannot reach has applied, which was the
|
|
// whole defect.
|
|
assert!(
|
|
down_row["applied_events"].is_null(),
|
|
"a down region's applied frontier must be null (unknown), not 0: {down_row}"
|
|
);
|
|
assert!(
|
|
down_row["lag_events"].is_null(),
|
|
"a down region's lag must be null (unknown), not a worst case derived from a \
|
|
fabricated applied=0: {down_row}"
|
|
);
|
|
}
|
|
|
|
/// `/cluster/reconcile` exchanges CRDT snapshots and converges both sides: a hide
|
|
/// recorded ONLY on us-east becomes visible on eu-west after reconcile, the timing
|
|
/// fields are reported, and a second reconcile is a no-op (idempotent).
|
|
#[test]
|
|
fn reconcile_converges_and_times() {
|
|
let s = Serving::start(&[0, 1]);
|
|
let client = &s.client;
|
|
|
|
// Record a hide on us-east (the leader) only. (Hardnegs converge via reconcile,
|
|
// not the WAL relay, so eu-west does not see it yet.)
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/hardnegs", s.base(0)),
|
|
&serde_json::json!({ "user_id": 42, "item_id": 7 }),
|
|
);
|
|
assert_eq!(
|
|
resp.status().as_u16(),
|
|
204,
|
|
"hardneg on leader: {}",
|
|
resp.status()
|
|
);
|
|
|
|
// Reconcile eu-west WITH us-east: eu-west drives the exchange, applying
|
|
// us-east's pre-merge snapshot (which carries the hide).
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/cluster/reconcile", s.base(1)),
|
|
&serde_json::json!({ "region": s.cluster.names[0] }),
|
|
);
|
|
assert_eq!(resp.status().as_u16(), 200, "reconcile: {}", resp.status());
|
|
let body: serde_json::Value = resp.json().unwrap();
|
|
assert_eq!(body["ok"].as_bool(), Some(true));
|
|
assert_eq!(body["region"].as_str(), Some(s.cluster.names[0].as_str()));
|
|
// Both timing fields are present (u64, possibly 0 on a fast localhost merge).
|
|
assert!(
|
|
body["local_elapsed_ms"].is_u64(),
|
|
"local_elapsed_ms reported: {body}"
|
|
);
|
|
assert!(
|
|
body["remote_elapsed_ms"].is_u64(),
|
|
"remote_elapsed_ms reported: {body}"
|
|
);
|
|
let first_ops = body["ops_applied"].as_u64().unwrap();
|
|
assert!(
|
|
first_ops >= 1,
|
|
"first reconcile must apply the hide: {body}"
|
|
);
|
|
|
|
// Capture eu-west's hardneg state after the first reconcile via take_snapshot
|
|
// (exposed over the internal snapshot route): the hide for (42, 7) is present.
|
|
let hardneg_present = |base: &str| -> bool {
|
|
// Drive the internal snapshot route with an EMPTY remote snapshot (a no-op
|
|
// merge) and read back the node's pre-merge snapshot, which lists every
|
|
// hard-negative register it holds.
|
|
let resp = client
|
|
.post(format!("{base}/cluster/reconcile/snapshot"))
|
|
.header("x-tidal-internal", "1")
|
|
.json(&serde_json::json!({ "signal_states": [], "hardneg_registers": [] }))
|
|
.send()
|
|
.unwrap();
|
|
assert_eq!(resp.status().as_u16(), 200, "snapshot route");
|
|
let body: serde_json::Value = resp.json().unwrap();
|
|
body["pre_merge_snapshot"]["hardneg_registers"]
|
|
.as_array()
|
|
.is_some_and(|regs| {
|
|
regs.iter().any(|r| {
|
|
// wire form: [user_id, item_id, register]
|
|
r.get(0).and_then(serde_json::Value::as_u64) == Some(42)
|
|
&& r.get(1).and_then(serde_json::Value::as_u64) == Some(7)
|
|
})
|
|
})
|
|
};
|
|
assert!(
|
|
hardneg_present(&s.base(1)),
|
|
"after reconcile, eu-west must hold the hide for (42, 7)"
|
|
);
|
|
|
|
// A second reconcile is idempotent IN EFFECT: the LWW plan re-resolves the
|
|
// same hide (the engine re-stamps Hide registers at now() per snapshot, so the
|
|
// op count is not 0 — that is the engine's honest semantics), but the
|
|
// converged STATE is unchanged: the hide is still present on both sides, never
|
|
// dropped or duplicated.
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/cluster/reconcile", s.base(1)),
|
|
&serde_json::json!({ "region": s.cluster.names[0] }),
|
|
);
|
|
assert_eq!(resp.status().as_u16(), 200);
|
|
assert!(
|
|
hardneg_present(&s.base(1)) && hardneg_present(&s.base(0)),
|
|
"second reconcile must leave both sides converged (hide present on both)"
|
|
);
|
|
}
|
|
|
|
/// `/sharded/feed` fans out across processes; with one node DOWN the response is
|
|
/// `degraded: true`, names the unavailable shard, and still merges results from
|
|
/// the live shards.
|
|
#[test]
|
|
fn sharded_feed_degrades_honestly() {
|
|
// Start nodes 0 and 1; ap-south (index 2) is DOWN.
|
|
let s = Serving::start(&[0, 1]);
|
|
let client = &s.client;
|
|
|
|
// Seed items + signals on BOTH live nodes' local stores via /sharded writes so
|
|
// each shard owns its slice. (Sharded writes route to the owner; a write whose
|
|
// owner is the DOWN node will 503, which we tolerate — we only need the live
|
|
// shards populated for the read fan-out.)
|
|
for i in 1..=12u64 {
|
|
let _ = post_sharded(
|
|
client,
|
|
&format!("{}/sharded/items", s.base(0)),
|
|
&serde_json::json!({ "entity_id": i, "metadata": { "title": format!("item {i}") } }),
|
|
);
|
|
let _ = post_sharded(
|
|
client,
|
|
&format!("{}/sharded/signals", s.base(0)),
|
|
&serde_json::json!({ "entity_id": i, "signal": "view", "weight": i as f64 }),
|
|
);
|
|
}
|
|
|
|
// Sharded feed from the leader, generous per-shard deadline so the LIVE shards
|
|
// always answer and only the DOWN shard degrades.
|
|
let feed: serde_json::Value = poll_json(
|
|
client,
|
|
&format!(
|
|
"{}/sharded/feed?profile=trending&limit=12&deadline_ms=2000",
|
|
s.base(0)
|
|
),
|
|
|f| f["scatter_gather"]["degraded"].as_bool() == Some(true),
|
|
);
|
|
let sg = &feed["scatter_gather"];
|
|
assert_eq!(
|
|
sg["degraded"].as_bool(),
|
|
Some(true),
|
|
"must be degraded: {feed}"
|
|
);
|
|
let unavailable = sg["unavailable_shards"].as_array().unwrap();
|
|
assert!(
|
|
unavailable
|
|
.iter()
|
|
.any(|u| u.as_str() == Some(s.cluster.names[2].as_str())),
|
|
"unavailable_shards must name the down region: {sg}"
|
|
);
|
|
// Still returns merged results from the live shards — never an error.
|
|
assert!(
|
|
!feed["items"].as_array().unwrap().is_empty(),
|
|
"degraded sharded feed must still return live-shard results: {feed}"
|
|
);
|
|
}
|
|
|
|
/// A `/sharded/items` write whose `ShardRouter` owner is a REMOTE region is
|
|
/// forwarded to that region and becomes readable via the owner's local feed.
|
|
#[test]
|
|
fn sharded_write_routes_to_owner() {
|
|
let s = Serving::start_all();
|
|
let client = &s.client;
|
|
|
|
// Find an entity id whose engine ShardRouter owner is NOT the leader (so the
|
|
// write must forward). Probe the entity_shard mapping the server uses by
|
|
// checking which region's local feed the item lands on after a /sharded write.
|
|
// The engine hash is deterministic; iterate ids until one owns to region 1 or 2.
|
|
let mut routed = false;
|
|
for entity_id in 1..=200u64 {
|
|
let resp = post_sharded(
|
|
client,
|
|
&format!("{}/sharded/items", s.base(0)),
|
|
&serde_json::json!({ "entity_id": entity_id, "metadata": { "title": format!("e{entity_id}") } }),
|
|
);
|
|
// 201 = created (locally or forwarded to a reachable owner).
|
|
if resp.status().as_u16() != 201 {
|
|
continue;
|
|
}
|
|
// Add a signal too so the item is rankable on its owner.
|
|
let _ = post_sharded(
|
|
client,
|
|
&format!("{}/sharded/signals", s.base(0)),
|
|
&serde_json::json!({ "entity_id": entity_id, "signal": "view", "weight": 5.0 }),
|
|
);
|
|
|
|
// Check each non-leader region's LOCAL feed: if the item appears there, it
|
|
// was routed to that owner (a remote region), proving sharded forwarding.
|
|
for owner in [1usize, 2] {
|
|
let feed: serde_json::Value = client
|
|
.get(format!("{}/feed?profile=trending&limit=50", s.base(owner)))
|
|
.send()
|
|
.unwrap()
|
|
.json()
|
|
.unwrap();
|
|
let on_owner = feed["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|it| it["entity_id"].as_u64() == Some(entity_id));
|
|
if on_owner {
|
|
routed = true;
|
|
break;
|
|
}
|
|
}
|
|
if routed {
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
routed,
|
|
"no sharded item routed to a remote region within 200 ids — sharded forwarding broken"
|
|
);
|
|
}
|