tidaldb/tidal-net/src/server.rs
jx12n a0399550d6 feat(m12p6): persist HNSW graph + bounded SIGTERM drain — boot loads, no rebuild
Boot now LOADS the per-slot HNSW graph instead of rebuilding it. Clean
shutdown writes {data_dir}/vector/<kind>__<slot>.usearch; the next open loads
it when it matches the durable corpus (seconds), falling back to a full rebuild
only when the graph is missing/stale/corrupt. Eliminates the multi-minute boot
rebuild (~50-70 min at 1M/1536-D) that let the WAL compact past a restarting
node and triggered the reseed cascade.

Graceful SIGTERM now actually runs the close: bounded_drain caps the post-signal
HTTP drain (TIDAL_SHUTDOWN_DRAIN_MS, default 15s) then runs the deterministic
close regardless — sibling keep-alive connections no longer block the drain past
the k8s 60s grace into a SIGKILL (which cannot run Drop). ClusterNode and
ShardReplica::shutdown are now &self (db handle is an ArcSwapOption) so the close
fires even when a stuck connection task holds an Arc.

Fix USearch insert to be a true upsert (remove+add): it was unconditional add,
which a multi:false index rejects on a reseeding follower's post-snapshot WAL
replay -> applied_events stalls -> catch-up deadlock -> unrecoverable cluster.

Also: circuit-breaker peer last-contact tracking; real k3s 1536-dim deploy +
recall findings (recall@10 0.9869, read p99 8.71ms @ 200rps @ 100k) in
docs/profiling/m12-cluster-deploy-findings.md; new tidal-stress k8s jobs and
m12p6 graph-persistence + SIGTERM tier-3 regression tests.
2026-06-15 13:09:20 -06:00

1548 lines
66 KiB
Rust

//! gRPC server implementing the `WalShipping` service.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tidaldb::replication::{shard::ShardId, transport::WalSegmentPayload};
use tokio::sync::mpsc;
use tonic::{Request, Response, Status};
use crate::{
config::GrpcTransportConfig,
proto::{
AppliedReport, AppliedReportAck, HeartbeatRequest, HeartbeatResponse, JoinRequest,
JoinResponse, MemberInfo, ShipSegmentRequest, ShipSegmentResponse, SnapshotChunk,
SnapshotFileChunk, SnapshotFileEntry, SnapshotHeader, SnapshotRequest, StreamRequest,
TimeoutNowRequest, TimeoutNowResponse, VoteRequest, VoteResponse, WalSegmentId,
wal_shipping_server::{WalShipping, WalShippingServer},
},
sources::ServingSources,
};
/// Shared per-peer applied-hint map (transport + service): the monotonic max
/// of every ship-ack hint AND every `ReportApplied` push for a peer. Both
/// inputs are durable-true (a follower's frontier only advances after its
/// own storage apply + WAL fsync).
pub(crate) type PeerAppliedMap = Arc<Mutex<HashMap<ShardId, u64>>>;
/// Fold a durable mark into the shared hint map (monotonic max; 0 ignored).
pub(crate) fn fold_peer_applied(map: &PeerAppliedMap, peer: ShardId, applied: u64) {
if applied == 0 {
return;
}
let mut hints = map
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let entry = hints.entry(peer).or_insert(0);
if applied > *entry {
*entry = applied;
}
drop(hints);
}
/// Leader-side last-seen capability bit-field per reporting peer (m11p5 §3.1).
/// Folded from `AppliedReport`s. The join/conf-change gate (stage C) reads it
/// to refuse appending kind-4 records until every voter reports the kind-4 bit.
/// Last-write-wins: a node's capabilities are fixed for a process lifetime, so
/// the freshest report is authoritative (a restart onto a downgraded binary —
/// itself a reseed-gated event — reports the new value).
pub(crate) type PeerCapabilityMap = Arc<Mutex<HashMap<ShardId, u64>>>;
/// Record a peer's reported capability bit-field (last-write-wins).
fn record_peer_capabilities(map: &PeerCapabilityMap, peer: ShardId, capabilities: u64) {
map.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(peer, capabilities);
}
/// The major component of a `"X.Y.Z"` semver string, if parseable.
fn version_major(v: &str) -> Option<u64> {
v.split('.').next()?.parse().ok()
}
/// Observe a heartbeat peer's build version for the m11p8 rolling-upgrade
/// handshake. Adjacent (N/N+1) versions interoperate by proto3 forward-compat;
/// a `>= 2` major-version gap is logged at WARN as an unsupported skew. Never
/// rejects — a rolling upgrade is a transient mixed-version window by design, so
/// the WARN only fires while such a window is open. An empty version is a
/// pre-m11p8 peer (version-unknown): no warning.
fn observe_peer_version(peer_region: u32, peer_version: &str) {
if peer_version.is_empty() {
return;
}
let ours = env!("CARGO_PKG_VERSION");
if peer_version == ours {
return;
}
match (version_major(peer_version), version_major(ours)) {
(Some(theirs), Some(mine)) if theirs.abs_diff(mine) >= 2 => {
tracing::warn!(
peer_region,
peer_version,
our_version = ours,
"rolling-upgrade version skew exceeds N/N+1 (>= 2 major versions apart); \
only adjacent versions are supported to interoperate"
);
}
_ => {
tracing::debug!(
peer_region,
peer_version,
our_version = ours,
"peer on a different build version (within the supported N/N+1 skew)"
);
}
}
}
/// The gRPC trailer key carrying the typed catch-up refusal class (m11p5 §2.4).
/// Values: `snapshot-required` | `rejoin` | `stepping-down`. A pre-m11p5 source
/// emits no trailer; the puller treats an absent/unknown value conservatively
/// (no reseed-marker latch) — a plain election term-mismatch must NOT poison a
/// fleet with reseed markers.
pub(crate) const CATCHUP_TRAILER: &str = "x-tidal-catchup";
/// Build a `FAILED_PRECONDITION` status carrying the typed catch-up trailer
/// (m11p5 §2.4). The trailer is metadata the puller reads to classify the
/// refusal; the message stays human-readable for logs.
fn catchup_refusal(class: &'static str, message: String) -> Status {
let mut status = Status::failed_precondition(message);
// `class` is a fixed ASCII literal (one of three known values), so the
// metadata-value parse never fails; on the impossible error path the
// status still carries the human-readable message (the puller falls back
// to today's log+retry behavior — never a panic).
if let Ok(value) = class.parse() {
status.metadata_mut().insert(CATCHUP_TRAILER, value);
}
status
}
/// Per-chunk caps for the `StreamSegments` catch-up path: small enough to
/// stay far below the codec/payload ceilings, large enough that a 100k-item
/// catch-up is a few hundred messages, not a few hundred thousand.
const STREAM_CHUNK_MAX_EVENTS: u64 = 4096;
const STREAM_CHUNK_MAX_BYTES: usize = 4 * 1024 * 1024;
/// Per-chunk byte cap for the `FetchSnapshot` file stream (1 MiB): far below
/// the codec ceiling, large enough that a multi-GiB artifact is thousands of
/// messages, not millions. Each chunk is read off `spawn_blocking`, never on
/// the reactor.
const SNAPSHOT_FILE_CHUNK_BYTES: usize = 1024 * 1024;
/// Read up to `max` bytes from `path` starting at `offset` (m11p5 snapshot
/// file chunking). Returns fewer bytes only at end-of-file (a short read of a
/// regular file is the file's tail; the caller stops once it reaches the
/// manifest size). Runs on `spawn_blocking`, never the reactor.
fn read_file_range(path: &std::path::Path, offset: u64, max: usize) -> std::io::Result<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(path)?;
file.seek(SeekFrom::Start(offset))?;
let mut buf = vec![0u8; max];
let mut filled = 0;
// Loop to fill the buffer: a single `read` may return short for reasons
// other than EOF, but a regular-file read on a local FS rarely does; the
// loop makes the chunk size deterministic regardless.
while filled < max {
let n = file.read(&mut buf[filled..])?;
if n == 0 {
break; // EOF
}
filled += n;
}
buf.truncate(filled);
Ok(buf)
}
/// The gRPC service that receives WAL segments from peers.
pub struct WalShippingService {
inbound_tx: mpsc::Sender<WalSegmentPayload>,
max_payload_bytes: usize,
/// Node-side read views (m11p2): applied-seqno for ack piggybacking and
/// WAL read-back for the catch-up stream. Absent = pre-m11p2 behavior.
/// m11p3 adds the `applied_sink` hook for follower frontier reports.
sources: ServingSources,
/// Per-peer durable marks shared with the transport (m11p3): folded from
/// `ReportApplied` pushes here and from ship acks on the client side.
peer_applied: PeerAppliedMap,
/// Per-peer last-seen capability bit-field (m11p5 §3.1), folded from
/// `AppliedReport`s. Exposed to the leader's conf-change gate (stage C).
peer_capabilities: PeerCapabilityMap,
}
impl WalShippingService {
/// Create a new service that forwards received segments to the given channel.
#[must_use]
pub(crate) const fn new(
inbound_tx: mpsc::Sender<WalSegmentPayload>,
max_payload_bytes: usize,
sources: ServingSources,
peer_applied: PeerAppliedMap,
peer_capabilities: PeerCapabilityMap,
) -> Self {
Self {
inbound_tx,
max_payload_bytes,
sources,
peer_applied,
peer_capabilities,
}
}
/// The applied-seqno ack hint for a segment from `source_shard` (0 when
/// no applied source is wired — the proto's "unknown").
fn applied_hint(&self, source_shard: u32) -> u64 {
let Ok(shard) = u16::try_from(source_shard) else {
return 0;
};
self.sources
.applied
.as_ref()
.map_or(0, |a| a.applied_seqno(ShardId(shard)))
}
}
// The async_trait expansion folds every handler into one item, so the lint
// must sit on the impl: ship_segment is one linear pass (size guard -> term
// fence -> convert -> enqueue) whose split would scatter the ack contract.
#[allow(clippy::too_many_lines)]
#[tonic::async_trait]
impl WalShipping for WalShippingService {
async fn ship_segment(
&self,
request: Request<ShipSegmentRequest>,
) -> Result<Response<ShipSegmentResponse>, Status> {
let req = request.into_inner();
// Validate payload size.
if req.payload.len() > self.max_payload_bytes {
return Err(Status::resource_exhausted(format!(
"payload {} bytes exceeds max {}",
req.payload.len(),
self.max_payload_bytes
)));
}
// Term fence (m11p4): a stale-term ship is rejected BEFORE it can
// enter the inbound queue — a deposed leader's traffic never reaches
// the apply path. A current/higher claim records leader contact (and
// any adoption persisted before this returns).
let response_term = if let Some(hooks) = self.sources.election.get() {
let leader_region = u16::try_from(req.leader_region)
.map_err(|_| Status::invalid_argument("leader_region exceeds u16 range"))?;
let first_seq = req.id.as_ref().map_or(0, |id| id.seqno);
match hooks.observe_leader_claim(req.term, leader_region, first_seq) {
Ok(()) => {}
Err(crate::sources::ClaimRejection::Stale { current_term }) => {
return Err(Status::failed_precondition(format!(
"stale leadership term {} (current term {current_term}); \
this ship is fenced",
req.term
)));
}
Err(crate::sources::ClaimRejection::JoinPending) => {
// Transient: the next heartbeat round runs the term-join
// check; UNAVAILABLE keeps the sender retrying instead of
// quarantining the peer.
return Err(Status::unavailable(format!(
"term {} not joined yet (awaiting the leader's heartbeat); retry",
req.term
)));
}
}
hooks.self_claim().0
} else {
0
};
let source_shard = req.id.as_ref().map_or(0, |id| id.shard_id);
// Convert proto to domain type.
let payload = WalSegmentPayload::try_from(req)
.map_err(|e| Status::invalid_argument(e.to_string()))?;
// The ack's applied hint: read BEFORE enqueue (the segment cannot
// have been applied yet) — a monotonic floor of this node's durable
// progress, always safe for the leader to fold. The ack deliberately
// does NOT wait for this segment's apply: holding acks couples the
// leader's ship cadence to apply latency, and one gap-parked
// follower then throttles its own feed into a death spiral
// (measured: total quorum collapse at 1k rps). Durable-frontier
// freshness flows the OTHER way instead — the receiver pushes
// `ReportApplied` once per apply round.
let applied_seqno = self.applied_hint(source_shard);
// Try to send on the inbound channel. On full, yield once and retry.
// If still full, return accepted=false. The WAL is durable on the
// leader, so the follower will catch up when capacity frees.
match self.inbound_tx.try_send(payload) {
Ok(()) => Ok(Response::new(ShipSegmentResponse {
accepted: true,
applied_seqno,
term: response_term,
})),
Err(mpsc::error::TrySendError::Full(payload)) => {
// Expected, healthy backpressure under a sustained ship burst: the
// apply consumer (a slow 1536-dim HNSW insert path) is draining the
// bounded inbound queue more slowly than the leader ships. We yield
// once, retry, and on a second full reply `accepted=false` — the
// leader's circuit breaker treats that as backpressure (NOT a
// failure), keeps the peer alive, and re-ships from its durable WAL.
// DEBUG, not WARN: this fires per-segment during a burst and would
// otherwise flood the log; the leader already rate-limits the
// matching "batch ship failing" WARN on its side.
tracing::debug!("inbound channel full; yielding and retrying");
tokio::task::yield_now().await;
match self.inbound_tx.try_send(payload) {
Ok(()) => Ok(Response::new(ShipSegmentResponse {
accepted: true,
applied_seqno,
term: response_term,
})),
Err(_) => Ok(Response::new(ShipSegmentResponse {
accepted: false,
applied_seqno,
term: response_term,
})),
}
}
Err(mpsc::error::TrySendError::Closed(_)) => {
Err(Status::unavailable("receiver shut down"))
}
}
}
type StreamSegmentsStream =
tokio_stream::wrappers::ReceiverStream<Result<ShipSegmentRequest, Status>>;
async fn stream_segments(
&self,
request: Request<StreamRequest>,
) -> Result<Response<Self::StreamSegmentsStream>, Status> {
let req = request.into_inner();
let Some(source) = self.sources.segments.clone() else {
// No WAL read-back wired (in-process tests, pre-m11p2 embedders):
// fail loudly with an actionable code rather than hanging.
return Err(Status::unimplemented(
"StreamSegments: no segment source wired on this node",
));
};
// Term fence (m11p4, design-review C14): the source serves only a
// SAME-TERM puller. A newer-term puller means this source is deposed
// — it must step down, not serve its possibly-divergent tail; a
// stale puller must rejoin the current term before pulling.
let (chunk_term, chunk_leader_region) = if let Some(hooks) = self.sources.election.get() {
let (current_term, self_region) = hooks.self_claim();
if req.term > current_term {
hooks.on_observed_term(req.term);
// Typed refusal (m11p5 §2.4): `stepping-down` — this source is
// deposed, not behind on data. The puller must NOT latch a
// reseed marker; it re-discovers the current leader and
// re-stamps its pull. A plain election term-mismatch poisoning
// the fleet with reseed markers is the conflation hazard the
// trailer closes.
return Err(catchup_refusal(
"stepping-down",
format!(
"this source's term {current_term} is behind the puller's {}; \
stepping down — pull from the current leader",
req.term
),
));
}
if req.term < current_term {
// Typed refusal (m11p5 §2.4): `rejoin` — the puller is on a
// stale term; it rejoins the current term and re-pulls. Not a
// reseed condition.
return Err(catchup_refusal(
"rejoin",
format!(
"puller term {} is behind the source's {current_term}; \
rejoin the current term before pulling",
req.term
),
));
}
(current_term, u32::from(self_region))
} else {
(0, 0)
};
// The stream serves THIS node's own log; a request for any other
// shard's stream is a routing error.
let serving = source.source_shard();
if req.shard_id != u32::from(serving.0) {
return Err(Status::not_found(format!(
"this node serves shard {} (requested stream for shard {})",
serving.0, req.shard_id
)));
}
// Clamp the start below the stream baseline: seqnos at or below it
// are pre-stream history (a promoted leader's pre-promotion WAL) that
// must never be served. Every chunk carries the baseline so the
// receiver jumps its frontier instead of parking on a phantom gap.
let baseline = source.stream_baseline();
let from = req.from_seqno.max(1).max(baseline + 1);
// Snapshot the end: the live unary push covers everything flushed
// after this stream started.
let end = source.flushed_seq();
let (tx, rx) = mpsc::channel::<Result<ShipSegmentRequest, Status>>(4);
tokio::spawn(async move {
let mut cursor = from;
while cursor <= end {
let source_for_read = Arc::clone(&source);
let read = tokio::task::spawn_blocking(move || {
source_for_read.collect_from(
cursor,
STREAM_CHUNK_MAX_EVENTS,
STREAM_CHUNK_MAX_BYTES,
)
})
.await;
let chunks = match read {
Ok(Ok(chunks)) => chunks,
// Unservable-by-design (m11p4): the leader's WAL cannot
// serve this range and never will — its segments carry a
// format this binary cannot read (rolling-upgrade
// residue) or there is no durable log. FAILED_PRECONDITION
// tells the follower "stop expecting this stream; you
// need a snapshot", distinct from a retryable INTERNAL.
Ok(Err(crate::sources::SegmentReadError::Unavailable { detail })) => {
// Typed refusal (m11p5 §2.4): `snapshot-required` — the
// log can never serve this range; the puller latches a
// reseed marker and fetches a snapshot.
let _ = tx
.send(Err(catchup_refusal(
"snapshot-required",
format!(
"segments not available from seq {cursor}; \
snapshot required ({detail})"
),
)))
.await;
return;
}
Ok(Err(crate::sources::SegmentReadError::Failed { detail })) => {
let _ = tx
.send(Err(Status::internal(format!(
"segment read-back failed at seqno {cursor}: {detail}"
))))
.await;
return;
}
Err(join_err) => {
let _ = tx
.send(Err(Status::internal(format!(
"segment read-back task failed: {join_err}"
))))
.await;
return;
}
};
if chunks.is_empty() {
break; // caught up to the snapshot end
}
// A first chunk that starts above the requested cursor (and
// above the baseline) means the segments below it were
// compacted away: the follower's hole cannot be served from
// this log. Surface it honestly — a silent skip would strand
// the follower parked forever; m11p5's snapshot transfer is
// the designed fix for this case.
if let Some(first) = chunks.first()
&& first.first_seq > cursor
{
// Typed refusal (m11p5 §2.4): `snapshot-required` — the
// history below the cursor was compacted; the puller
// latches a reseed marker and fetches a snapshot.
let _ = tx
.send(Err(catchup_refusal(
"snapshot-required",
format!(
"WAL compacted below seqno {cursor} (earliest available {}); \
this follower needs a full resync (snapshot transfer, m11p5)",
first.first_seq
),
)))
.await;
return;
}
for chunk in chunks {
cursor = chunk.last_seq + 1;
let msg = ShipSegmentRequest {
id: Some(WalSegmentId {
region_id: 0,
shard_id: u32::from(serving.0),
seqno: chunk.first_seq,
}),
payload: chunk.bytes,
event_count: chunk.event_count,
leader_last_seq: chunk.last_seq,
stream_baseline: baseline,
term: chunk_term,
leader_region: chunk_leader_region,
};
if tx.send(Ok(msg)).await.is_err() {
return; // client went away; stop reading
}
}
}
});
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
rx,
)))
}
type FetchSnapshotStream =
tokio_stream::wrappers::ReceiverStream<Result<SnapshotChunk, Status>>;
async fn fetch_snapshot(
&self,
request: Request<SnapshotRequest>,
) -> Result<Response<Self::FetchSnapshotStream>, Status> {
let req = request.into_inner();
let Some(source) = self.sources.snapshots.get().cloned() else {
// No snapshot source wired (in-process tests, pre-m11p5 embedders).
// §3.7: a pre-m11p5 peer answers Unimplemented; the joiner reports
// it loudly and retries the next seed.
return Err(Status::unimplemented(
"FetchSnapshot: no snapshot source wired on this node",
));
};
// Term fence (m11p5 §2): a deposed source must NOT serve a snapshot of
// its possibly-divergent state; a stale puller must rejoin first.
// Mirrors StreamSegments exactly — same-term puller only.
let (snap_term, leader_region) = if let Some(hooks) = self.sources.election.get() {
let (current_term, self_region) = hooks.self_claim();
if req.term > current_term {
hooks.on_observed_term(req.term);
return Err(catchup_refusal(
"stepping-down",
format!(
"this source's term {current_term} is behind the puller's {}; \
stepping down — fetch the snapshot from the current leader",
req.term
),
));
}
if req.term < current_term {
return Err(catchup_refusal(
"rejoin",
format!(
"puller term {} is behind the source's {current_term}; \
rejoin the current term before fetching a snapshot",
req.term
),
));
}
(current_term, u32::from(self_region))
} else {
(0, 0)
};
// Stage (or reuse) the artifact. This call must be cheap — it reuses an
// already-staged artifact when the live WAL still covers the request —
// so running it on the reactor is intentional. The pin (§2.1) is taken
// inside `stage` and released when this stream ends.
let staging = match source.stage(req.from_seqno) {
Ok(staging) => staging,
Err(crate::sources::SnapshotStageError::Busy) => {
// RETRYABLE (§2): a concurrent stage is mid-flight; the joiner
// retries and shares it. NEVER FAILED_PRECONDITION — that would
// mis-route the joiner into the reseed class.
return Err(Status::unavailable(
"snapshot staging in progress; retry shortly",
));
}
Err(crate::sources::SnapshotStageError::Failed(detail)) => {
return Err(Status::internal(format!(
"snapshot staging failed: {detail}"
)));
}
};
// A `needed=false` staging holds no pin (§2.1) — emit the header and
// finish without touching `release`. A `needed=true` staging owns a
// pin that the stream task releases on completion or client drop.
let header = SnapshotHeader {
needed: staging.needed,
snapshot_seq: staging.snapshot_seq,
term: snap_term,
leader_region,
files: staging
.files
.iter()
.map(|(path, size, blake3)| SnapshotFileEntry {
path: path.clone(),
size: *size,
blake3: blake3.to_vec(),
})
.collect(),
};
let (tx, rx) = mpsc::channel::<Result<SnapshotChunk, Status>>(4);
tokio::spawn(async move {
// The pin guard: a `needed=true` staging is released exactly once
// when this task ends, by ANY exit (header send failure, a file
// read error, the last chunk, or the client dropping mid-stream).
// A `needed=false` staging never took a pin, so it must not call
// `release` (the node-side impl is idempotent, but not pinning at
// all is cleaner). `ReleaseGuard` makes the single-release rule a
// structural property, not a discipline scattered across returns.
struct ReleaseGuard {
source: Arc<dyn crate::sources::SnapshotSource>,
armed: bool,
}
impl Drop for ReleaseGuard {
fn drop(&mut self) {
if self.armed {
self.source.release();
}
}
}
let _guard = ReleaseGuard {
source: Arc::clone(&source),
armed: staging.needed,
};
if tx
.send(Ok(SnapshotChunk {
chunk: Some(crate::proto::snapshot_chunk::Chunk::Header(header)),
}))
.await
.is_err()
{
return; // client went away before the header landed
}
if !staging.needed {
return; // no files follow a no-snapshot-needed header
}
for (rel_path, size, _blake3) in &staging.files {
let abs = staging.root.join(rel_path);
let mut offset: u64 = 0;
// Read the file in 1 MiB chunks off `spawn_blocking`. BLAKE3 is
// NOT recomputed here — the manifest hash (computed once when
// the artifact was staged) is the contract; the puller verifies
// the received bytes against it end to end.
loop {
let path = abs.clone();
let read = tokio::task::spawn_blocking(move || {
read_file_range(&path, offset, SNAPSHOT_FILE_CHUNK_BYTES)
})
.await;
let data = match read {
Ok(Ok(data)) => data,
Ok(Err(e)) => {
let _ = tx
.send(Err(Status::internal(format!(
"snapshot file '{rel_path}' read failed at offset {offset}: {e}"
))))
.await;
return;
}
Err(join_err) => {
let _ = tx
.send(Err(Status::internal(format!(
"snapshot file read task failed: {join_err}"
))))
.await;
return;
}
};
let read_len = data.len() as u64;
let next_offset = offset.saturating_add(read_len);
// `last` is true when this chunk reaches the manifest size,
// including the empty-file case (a zero-byte read at
// offset 0 must still emit one terminal chunk so the
// receiver sees the file).
let last = next_offset >= *size;
let send_ok = tx
.send(Ok(SnapshotChunk {
chunk: Some(crate::proto::snapshot_chunk::Chunk::File(
SnapshotFileChunk {
path: rel_path.clone(),
offset,
data,
last,
},
)),
}))
.await
.is_ok();
if !send_ok {
return; // client went away mid-file
}
if last {
break;
}
offset = next_offset;
}
}
});
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
rx,
)))
}
async fn heartbeat(
&self,
request: Request<HeartbeatRequest>,
) -> Result<Response<HeartbeatResponse>, Status> {
// A heartbeat that reaches this handler proves the gRPC server is up,
// the listener is accepting, and (under mTLS) the peer's certificate
// was accepted — a genuine network-liveness probe. With m11p4 it is
// also the leader's LEASE ASSERTION: the election hooks fold the
// term/leadership claim into the failure detector and answer with
// this node's term (a higher one is the sender's step-down signal).
let req = request.into_inner();
tracing::debug!(
shard_id = req.shard_id,
region_id = req.region_id,
term = req.term,
"received heartbeat",
);
// m11p8 rolling-upgrade handshake: observe the peer's build version.
// N/N+1 (adjacent major) interoperate by proto3 forward-compat; a
// >= 2-major gap is the loud signal that the skew exceeds what is
// supported. Never a rejection — a rolling upgrade is a transient mixed
// window by design (the warn only fires during such a window).
observe_peer_version(req.region_id, &req.build_version);
if let Some(hooks) = self.sources.election.get() {
let leader_region = u16::try_from(req.leader_region)
.map_err(|_| Status::invalid_argument("leader_region exceeds u16 range"))?;
// The typed removed signal (m11p5 §3.3): is the heartbeat SENDER
// (`region_id`, NOT the claimed `leader_region`) a `Removed` member
// in THIS node's applied roster? Computed from `region_id` so a
// removed FOLLOWER also learns it (not only when it claims
// leadership). A bad region_id (out of u16) cannot be a roster
// member, so it is trivially "not removed".
let removed =
u16::try_from(req.region_id).is_ok_and(|sender| hooks.is_removed_member(sender));
let verdict = hooks.on_heartbeat(crate::HeartbeatContext {
term: req.term,
leader_region,
stream_baseline: req.stream_baseline,
prev_log: tidaldb::replication::LogPosition {
tail_term: req.prev_log_term,
frontier: req.prev_log_seq,
},
leader_last_seq: req.leader_last_seq,
});
return Ok(Response::new(HeartbeatResponse {
acknowledged: true,
term: verdict.term,
accepted: verdict.accepted,
// This binary's capabilities (m11p5 §3.1): the sender folds
// this into its per-peer capability view to gate conf-changes.
capabilities: crate::CAPABILITIES,
removed,
}));
}
// Pre-m11p4 behavior (no election driver wired): a truthful
// reachability ack. No roster ⇒ no removed members.
Ok(Response::new(HeartbeatResponse {
acknowledged: true,
term: 0,
accepted: true,
capabilities: crate::CAPABILITIES,
removed: false,
}))
}
async fn report_applied(
&self,
request: Request<AppliedReport>,
) -> Result<Response<AppliedReportAck>, Status> {
let report = request.into_inner();
let (Ok(reporter), Ok(source)) = (
u16::try_from(report.reporter_shard),
u16::try_from(report.source_shard),
) else {
return Err(Status::invalid_argument("shard ids must fit u16"));
};
let reporter = ShardId(reporter);
// A report is addressed to the stream's SOURCE; when this node's
// serving identity is known (segment source wired), refuse a report
// for some other node's stream — folding a mark into the wrong
// quorum would be silent corruption.
if let Some(segments) = &self.sources.segments
&& segments.source_shard() != ShardId(source)
{
return Err(Status::not_found(format!(
"this node serves shard {} (report addressed to shard {source})",
segments.source_shard().0
)));
}
// Record the reporter's capabilities (m11p5 §3.1) BEFORE the term
// gate: a capability bit is a term-independent fact about the
// reporter's binary, and the conf-change gate must learn it even from
// a reporter whose term is momentarily stale (an in-progress election
// must not blind the leader to who is kind-4 capable).
record_peer_capabilities(&self.peer_capabilities, reporter, report.capabilities);
// Term fence (m11p4, design-review C4/C8/C12): a report from any
// term but the current leadership's never reaches the hint map or
// the commit index — a deposed leader's followers (or a delayed
// report from a previous leadership) cannot advance commitment. The
// sink re-checks under the commit index's own lock (the race-free
// gate); this handler-level check keeps the HINT MAP equally clean.
if let Some(hooks) = self.sources.election.get()
&& !hooks.report_term_acceptable(report.reporter_term)
{
return Err(Status::failed_precondition(format!(
"frontier report term {} does not match the current leadership term {}; \
report dropped",
report.reporter_term,
hooks.self_claim().0
)));
}
fold_peer_applied(&self.peer_applied, reporter, report.applied_seqno);
if let Some(sink) = self.sources.applied_sink.get() {
sink.peer_applied(reporter, report.applied_seqno, report.reporter_term);
}
Ok(Response::new(AppliedReportAck { acknowledged: true }))
}
async fn request_vote(
&self,
request: Request<VoteRequest>,
) -> Result<Response<VoteResponse>, Status> {
let Some(hooks) = self.sources.election.get() else {
return Err(Status::unimplemented(
"RequestVote: no election driver on this node (pre-m11p4 binary or bare \
transport)",
));
};
let req = request.into_inner();
let candidate = u16::try_from(req.candidate_region)
.map_err(|_| Status::invalid_argument("candidate_region exceeds u16 range"))?;
// The typed removed signal (m11p5 §3.3): is the CANDIDATE a `Removed`
// member in this voter's applied roster? A removed node still campaigns
// until it learns of its removal; this refusal teaches it.
let removed = hooks.is_removed_member(candidate);
let reply = hooks.on_vote(tidaldb::replication::VoteRpc {
term: req.term,
candidate: tidaldb::replication::shard::RegionId(candidate),
log: tidaldb::replication::LogPosition {
tail_term: req.last_log_term,
frontier: req.last_log_seq,
},
prevote: req.prevote,
transfer: req.transfer,
});
Ok(Response::new(VoteResponse {
term: reply.term,
granted: reply.granted,
removed,
}))
}
async fn timeout_now(
&self,
request: Request<TimeoutNowRequest>,
) -> Result<Response<TimeoutNowResponse>, Status> {
let Some(hooks) = self.sources.election.get() else {
return Err(Status::unimplemented(
"TimeoutNow: no election driver on this node",
));
};
let req = request.into_inner();
let leader_region = u16::try_from(req.leader_region)
.map_err(|_| Status::invalid_argument("leader_region exceeds u16 range"))?;
let accepted = hooks.on_timeout_now(req.term, leader_region);
Ok(Response::new(TimeoutNowResponse { accepted }))
}
async fn join_cluster(
&self,
request: Request<JoinRequest>,
) -> Result<Response<JoinResponse>, Status> {
let Some(hooks) = self.sources.join.get().cloned() else {
// §3.7: a pre-m11p5 peer answers Unimplemented; the joiner reports
// it loudly and retries the next seed.
return Err(Status::unimplemented(
"JoinCluster: no membership runtime on this node (pre-m11p5 binary or bare \
transport)",
));
};
let req = request.into_inner();
let ask = crate::sources::JoinAsk {
name: req.name,
grpc_addr: req.grpc_addr,
http_addr: req.http_addr,
capabilities: req.capabilities,
};
// The hook BLOCKS on the bounded same-term commit wait (§3.3): run it on
// `spawn_blocking` so the reactor is never parked. A join task panic
// surfaces as INTERNAL rather than killing the worker.
let outcome = tokio::task::spawn_blocking(move || hooks.join(ask))
.await
.map_err(|e| Status::internal(format!("join task failed: {e}")))?;
Ok(Response::new(JoinResponse {
accepted: outcome.accepted,
refusal_reason: outcome.refusal_reason,
assigned_id: u32::from(outcome.assigned_id),
term: outcome.term,
leader_region: outcome.leader_region,
leader_grpc_addr: outcome.leader_grpc_addr,
leader_http_addr: outcome.leader_http_addr,
members: outcome
.members
.into_iter()
.map(|m| MemberInfo {
id: u32::from(m.id),
name: m.name,
grpc_addr: m.grpc_addr,
http_addr: m.http_addr,
role: u32::from(m.role),
})
.collect(),
membership_version: outcome.membership_version,
}))
}
}
/// Start the gRPC server on the given address.
///
/// Returns a `JoinHandle` that resolves when the server stops. Must be called
/// from within a tokio runtime context (it `tokio::spawn`s the serve loop).
///
/// # TLS posture (m11p7)
///
/// - `server_resolver = Some(_)` → mutual TLS. The server is served over a
/// custom `tokio-rustls` acceptor (NOT tonic's fixed `.tls_config()`) so the
/// node's certificate is hot-swappable via the resolver. The accept loop runs
/// each handshake in its own task and forwards ONLY successful streams to
/// tonic, so a foreign pod — no client cert, a cert from another CA, or a
/// plaintext probe — fails the handshake and never reaches an RPC.
/// - `server_resolver = None` and `config.insecure` → plaintext, with a loud
/// startup WARN (the trusted-loopback opt-in).
/// - `server_resolver = None` and NOT `config.insecure` → a typed error
/// (refuse to serve unauthenticated).
///
/// # Errors
///
/// Returns [`GrpcTransportError`](crate::error::GrpcTransportError) if the mTLS
/// listener cannot bind, the server config cannot be built, or TLS is unset and
/// `insecure` was not opted into.
pub(crate) fn start_server(
config: &GrpcTransportConfig,
inbound_tx: mpsc::Sender<WalSegmentPayload>,
sources: ServingSources,
peer_applied: PeerAppliedMap,
peer_capabilities: PeerCapabilityMap,
server_resolver: Option<Arc<crate::tls::DynamicCertResolver>>,
shutdown: Arc<crate::transport::ShutdownSignal>,
) -> Result<
tokio::task::JoinHandle<Result<(), tonic::transport::Error>>,
crate::error::GrpcTransportError,
> {
let service = WalShippingService::new(
inbound_tx,
config.max_payload_bytes,
sources,
peer_applied,
peer_capabilities,
);
let addr = config.listen_addr;
// Raise tonic's default 4 MiB codec limits to the configured max payload on
// BOTH directions. The server is the DECODER for inbound ShipSegmentRequest
// (and the ENCODER for the StreamSegments response stream). With the
// default 4 MiB limit a full-size WAL segment (16 MiB by default, up to
// `max_payload_bytes` = 64 MiB) is rejected inside the codec with a
// "message too large" status, which the shipper would retry forever as a
// transient `Closed`. Pin both limits to the same ceiling the application
// already advertises and validates against (server.rs ship_segment guard,
// transport.rs send_segment guard) so the wire path and the app agree.
let max = config.max_payload_bytes;
let wal_service = WalShippingServer::new(service)
.max_decoding_message_size(max)
.max_encoding_message_size(max);
if let Some(resolver) = server_resolver {
// mTLS path. Build the rustls config (CA-rooted client verifier + the
// hot-swappable cert resolver + ALPN h2), then serve over a tokio-rustls
// acceptor whose accept loop drops failed handshakes before tonic.
let tls = config.tls.as_ref().ok_or_else(|| {
crate::error::GrpcTransportError::TlsConfig(
"server cert resolver present but TLS config absent".into(),
)
})?;
let server_config = crate::tls::build_server_config(tls, resolver)?;
// Bind synchronously so "address already in use" surfaces immediately as
// a typed error (tonic's `.serve(addr)` binds lazily inside the future,
// which would only fail later via the JoinHandle). Hand the std listener
// to the task, which registers it with the reactor.
let std_listener = std::net::TcpListener::bind(addr).map_err(|e| {
crate::error::GrpcTransportError::Internal(format!("bind mTLS listener {addr}: {e}"))
})?;
std_listener.set_nonblocking(true).map_err(|e| {
crate::error::GrpcTransportError::Internal(format!(
"set mTLS listener non-blocking {addr}: {e}"
))
})?;
let acceptor = tokio_rustls::TlsAcceptor::from(server_config);
let handle = tokio::spawn(serve_mtls(
std_listener,
acceptor,
wal_service,
shutdown,
addr,
config.handshake_timeout,
config.max_concurrent_handshakes,
));
return Ok(handle);
}
// Plaintext path: refuse unless explicitly opted into, and WARN loudly when
// we do serve cleartext.
if !config.insecure {
return Err(crate::error::GrpcTransportError::TlsConfig(
"TLS not configured and insecure not set".into(),
));
}
tracing::warn!(
%addr,
"gRPC replication listener is PLAINTEXT (insecure=true): WAL segments, election \
traffic, snapshots, and conf-changes cross the network UNENCRYPTED and \
UNAUTHENTICATED — a foreign pod on this network can ship segments and impersonate a \
peer. Configure grpc_tls for mutual TLS. Acceptable ONLY on a trusted single-host / \
loopback topology."
);
let mut server_builder = tonic::transport::Server::builder();
let handle = tokio::spawn(async move {
// Observe the serve loop's terminal Result here so a failure AFTER
// successful startup (e.g. the listener dies, the reactor is torn down)
// is logged at error! rather than silently swallowed when the JoinHandle
// is dropped or aborted. The Result is still propagated out of the task
// so callers can also inspect it via the handle.
let result = server_builder.add_service(wal_service).serve(addr).await;
if let Err(ref e) = result {
tracing::error!(error = %e, %addr, "gRPC WAL-shipping serve loop terminated with error");
} else {
tracing::info!(%addr, "gRPC WAL-shipping serve loop stopped");
}
result
});
Ok(handle)
}
/// Serve the gRPC service over a `tokio-rustls` acceptor with hot-swappable
/// certs (m11p7). The accept loop runs each TLS handshake in its own task and
/// forwards ONLY a successfully-handshaken stream into tonic, so a foreign pod's
/// failed handshake is dropped before any RPC is dispatched.
///
/// Lifecycle: the accept loop and the serve future both exit on the shared
/// `shutdown` latch, so a transport `Drop` (which trips the latch before
/// aborting the `JoinHandle`) tears the whole inbound path down
/// deterministically; the abort is a backstop.
async fn serve_mtls(
std_listener: std::net::TcpListener,
acceptor: tokio_rustls::TlsAcceptor,
service: WalShippingServer<WalShippingService>,
shutdown: Arc<crate::transport::ShutdownSignal>,
addr: std::net::SocketAddr,
handshake_timeout: std::time::Duration,
max_concurrent_handshakes: usize,
) -> Result<(), tonic::transport::Error> {
let listener = match tokio::net::TcpListener::from_std(std_listener) {
Ok(listener) => listener,
Err(e) => {
// The listener cannot register with the reactor — the serve loop is
// effectively dead. Logged at error!; the empty Ok lets the
// JoinHandle finish so `serve_loop_died()` observes it (no shutdown
// was requested), demoting this follower rather than black-holing.
tracing::error!(%addr, error = %e, "mTLS listener registration failed");
return Ok(());
}
};
// A bounded channel of successfully-handshaken TLS streams. tonic implements
// `Connected` for `tokio_rustls::server::TlsStream`, so the receiver stream
// feeds `serve_with_incoming` directly and the peer's client cert surfaces in
// request extensions for inter-node identity.
let (conn_tx, conn_rx) = mpsc::channel::<
Result<tokio_rustls::server::TlsStream<tokio::net::TcpStream>, std::io::Error>,
>(128);
let accept_shutdown = Arc::clone(&shutdown);
// Bound the number of in-flight handshakes so a connection flood against the
// (pre-auth) accept path cannot spawn unbounded tasks / exhaust fds.
let handshake_limiter = Arc::new(tokio::sync::Semaphore::new(max_concurrent_handshakes));
let accept_loop = tokio::spawn(async move {
loop {
let accepted = tokio::select! {
biased;
() = accept_shutdown.wait() => break,
result = listener.accept() => result,
};
let (tcp, peer) = match accepted {
Ok(pair) => pair,
Err(e) => {
// A transient accept error (a reset during accept) is fine to
// retry immediately. A PERSISTENT one — most importantly
// EMFILE/ENFILE (fd-table exhaustion) — returns Err without
// consuming the pending connection, so retrying with no pause
// pegs a core. A brief backoff caps the spin while preserving
// liveness: the loop resumes the instant fds free up. (tonic's
// own AddrIncoming sleeps on accept errors for the same reason;
// the hand-rolled mTLS loop must do it explicitly.)
tracing::debug!(%addr, error = %e, "mTLS accept error; backing off");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
continue;
}
};
// Load-shed under a handshake flood: if the limiter is saturated, drop
// this connection now rather than queue an unbounded task behind it.
let Ok(permit) = Arc::clone(&handshake_limiter).try_acquire_owned() else {
tracing::debug!(%peer, "inbound handshake limiter saturated; dropping connection");
drop(tcp);
continue;
};
let acceptor = acceptor.clone();
let conn_tx = conn_tx.clone();
// Handshake off the accept path so one slow/foreign handshake cannot
// head-of-line block the next connection.
tokio::spawn(async move {
// Held for the whole handshake; released (load capacity returned)
// the instant this task ends, success or failure.
let _permit = permit;
match tokio::time::timeout(handshake_timeout, acceptor.accept(tcp)).await {
Ok(Ok(stream)) => {
// Channel closed = serve loop gone; drop the stream.
let _ = conn_tx.send(Ok(stream)).await;
}
Ok(Err(e)) => {
// Foreign pod (no/invalid client cert), a cert from
// another CA, or a non-TLS probe: the handshake fails
// HERE and the connection NEVER reaches tonic — the
// negative-test guarantee that a foreign pod cannot ship
// segments or call any RPC.
tracing::debug!(
%peer,
error = %e,
"rejected inbound gRPC handshake (no/invalid client cert or non-TLS probe)"
);
}
Err(_elapsed) => {
// A peer that connected but stalled the ClientHello
// (slowloris): abandon it so it cannot pin a task/socket.
tracing::debug!(%peer, "inbound gRPC handshake timed out; dropping connection");
}
}
});
}
});
let serve_shutdown = Arc::clone(&shutdown);
let result = tonic::transport::Server::builder()
.add_service(service)
.serve_with_incoming_shutdown(
tokio_stream::wrappers::ReceiverStream::new(conn_rx),
async move { serve_shutdown.wait().await },
)
.await;
// Serving has ended (shutdown or error); stop the accept loop too.
accept_loop.abort();
if let Err(ref e) = result {
tracing::error!(error = %e, %addr, "gRPC mTLS serve loop terminated with error");
} else {
tracing::info!(%addr, "gRPC mTLS serve loop stopped");
}
result
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] // test assertions on known-good fixtures
mod tests {
use super::*;
use crate::{
proto::{ShipSegmentRequest, WalSegmentId},
sources::SegmentChunk,
};
fn make_request(payload_len: usize) -> ShipSegmentRequest {
ShipSegmentRequest {
id: Some(WalSegmentId {
region_id: 0,
shard_id: 0,
seqno: 1,
}),
payload: vec![0xAB; payload_len],
event_count: 1,
leader_last_seq: 1,
stream_baseline: 0,
term: 0,
leader_region: 0,
}
}
/// SUGGESTION (tidal-net): pin the server-side payload boundary semantics so a
/// future change to one guard cannot silently desync from the client/codec.
/// The contract (shared with `GrpcTransport::send_segment` in transport.rs and
/// the codec limits in `start_server`) is **inclusive at `max_payload_bytes`**:
/// a payload of exactly `max` is ACCEPTED, and `max + 1` is REJECTED
/// (`len() > max`, not `>=`). This exercises the real `ship_segment` handler at
/// both sides of the boundary.
#[test]
fn ship_segment_payload_boundary_is_inclusive_at_max() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let max = 1024usize;
let (tx, mut rx) = mpsc::channel(4);
let service = WalShippingService::new(
tx,
max,
ServingSources::default(),
Arc::new(Mutex::new(HashMap::new())),
Arc::new(Mutex::new(HashMap::new())),
);
// Exactly max: accepted, and forwarded onto the inbound channel.
let resp = service
.ship_segment(Request::new(make_request(max)))
.await
.expect("a payload of exactly max_payload_bytes must be accepted");
assert!(
resp.into_inner().accepted,
"payload == max must be accepted (inclusive boundary)"
);
let forwarded = rx.try_recv().expect("accepted payload reaches the channel");
assert_eq!(forwarded.bytes.len(), max);
// max + 1: rejected with resource_exhausted, nothing forwarded.
let err = service
.ship_segment(Request::new(make_request(max + 1)))
.await
.expect_err("a payload of max + 1 must be rejected");
assert_eq!(
err.code(),
tonic::Code::ResourceExhausted,
"over-size payload must be rejected as resource_exhausted"
);
assert!(
rx.try_recv().is_err(),
"a rejected payload must not be forwarded onto the inbound channel"
);
});
}
/// The ship ack piggybacks the applied source's CURRENT floor for the
/// request's source shard — instantly, never waiting on the segment's
/// own apply (m11p3 keeps acks decoupled from apply latency by design;
/// durable freshness flows via `ReportApplied` instead).
#[test]
fn ship_segment_ack_carries_applied_floor() {
struct FixedApplied;
impl crate::sources::AppliedSource for FixedApplied {
fn applied_seqno(&self, source_shard: ShardId) -> u64 {
u64::from(source_shard.0) + 40
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let (tx, _rx) = mpsc::channel(4);
let sources = ServingSources {
applied: Some(Arc::new(FixedApplied)),
segments: None,
..ServingSources::default()
};
let service = WalShippingService::new(
tx,
1024,
sources,
Arc::new(Mutex::new(HashMap::new())),
Arc::new(Mutex::new(HashMap::new())),
);
let mut req = make_request(8);
req.id.as_mut().unwrap().shard_id = 2;
let resp = service
.ship_segment(Request::new(req))
.await
.unwrap()
.into_inner();
assert!(resp.accepted);
assert_eq!(resp.applied_seqno, 42, "ack must carry shard 2's floor");
});
}
/// m11p3: a follower's `ReportApplied` folds its durable mark into the
/// shared hint map AND the late-bound applied sink (the quorum input);
/// stale reports never regress; a report addressed to another node's
/// stream is refused.
#[test]
fn report_applied_folds_marks_and_feeds_the_sink() {
struct RecordingSink(Mutex<Vec<(ShardId, u64)>>);
impl crate::sources::AppliedSink for RecordingSink {
fn peer_applied(&self, peer: ShardId, applied: u64, _reporter_term: u64) {
self.0.lock().unwrap().push((peer, applied));
}
}
struct FixedSegments;
impl crate::sources::SegmentSource for FixedSegments {
fn source_shard(&self) -> ShardId {
ShardId(0)
}
fn stream_baseline(&self) -> u64 {
0
}
fn flushed_seq(&self) -> u64 {
0
}
fn collect_from(
&self,
_from_seq: u64,
_max_events: u64,
_max_bytes: usize,
) -> Result<Vec<SegmentChunk>, crate::sources::SegmentReadError> {
Ok(vec![])
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let sink = Arc::new(RecordingSink(Mutex::new(Vec::new())));
let sources = ServingSources {
applied: None,
segments: Some(Arc::new(FixedSegments)),
..ServingSources::default()
};
sources.set_applied_sink(Arc::clone(&sink) as Arc<dyn crate::sources::AppliedSink>);
let map: PeerAppliedMap = Arc::new(Mutex::new(HashMap::new()));
let (tx, _rx) = mpsc::channel(4);
let service = WalShippingService::new(
tx,
1024,
sources,
Arc::clone(&map),
Arc::new(Mutex::new(HashMap::new())),
);
let report = |reporter, source, applied| AppliedReport {
reporter_shard: reporter,
source_shard: source,
applied_seqno: applied,
reporter_term: 0,
capabilities: crate::CAP_KIND4_MEMBERSHIP,
};
service
.report_applied(Request::new(report(2, 0, 9)))
.await
.expect("report for this node's stream is accepted");
// A stale (lower) report folds into the sink but must not
// regress the hint map.
service
.report_applied(Request::new(report(2, 0, 5)))
.await
.unwrap();
assert_eq!(map.lock().unwrap().get(&ShardId(2)), Some(&9));
assert_eq!(
sink.0.lock().unwrap().as_slice(),
&[(ShardId(2), 9), (ShardId(2), 5)],
"the sink receives every report verbatim (it owns monotonicity)"
);
// Wrong stream: refused loudly.
let err = service
.report_applied(Request::new(report(2, 7, 11)))
.await
.expect_err("a report addressed to another node's stream is a routing bug");
assert_eq!(err.code(), tonic::Code::NotFound);
});
}
/// m11p2: the catch-up stream serves chunks from the segment source in
/// order, stamps every message with the stream baseline, and stops at the
/// snapshot end.
#[test]
fn stream_segments_serves_chunks_with_baseline() {
struct FakeSegments;
impl crate::sources::SegmentSource for FakeSegments {
fn source_shard(&self) -> ShardId {
ShardId(3)
}
fn stream_baseline(&self) -> u64 {
10
}
fn flushed_seq(&self) -> u64 {
20
}
fn collect_from(
&self,
from_seq: u64,
_max_events: u64,
_max_bytes: usize,
) -> Result<Vec<SegmentChunk>, crate::sources::SegmentReadError> {
// Two five-seqno chunks: [11..15], [16..20].
if from_seq <= 15 {
Ok(vec![SegmentChunk {
bytes: vec![1; 8],
first_seq: from_seq,
last_seq: 15,
event_count: 15 - from_seq + 1,
}])
} else if from_seq <= 20 {
Ok(vec![SegmentChunk {
bytes: vec![2; 8],
first_seq: from_seq,
last_seq: 20,
event_count: 20 - from_seq + 1,
}])
} else {
Ok(vec![])
}
}
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
use tokio_stream::StreamExt;
let (tx, _rx) = mpsc::channel(4);
let sources = ServingSources {
applied: None,
segments: Some(Arc::new(FakeSegments)),
..ServingSources::default()
};
let service = WalShippingService::new(
tx,
1024,
sources,
Arc::new(Mutex::new(HashMap::new())),
Arc::new(Mutex::new(HashMap::new())),
);
// Request from seqno 1: the baseline (10) clamps the start to 11.
let resp = service
.stream_segments(Request::new(StreamRequest {
shard_id: 3,
from_seqno: 1,
term: 0,
}))
.await
.expect("stream must open");
let mut stream = resp.into_inner();
let mut msgs = Vec::new();
while let Some(msg) = stream.next().await {
msgs.push(msg.expect("chunk"));
}
assert_eq!(msgs.len(), 2, "two chunks cover [11..20]");
assert_eq!(msgs[0].id.as_ref().unwrap().seqno, 11, "baseline clamps");
assert_eq!(msgs[0].leader_last_seq, 15);
assert_eq!(msgs[0].stream_baseline, 10);
assert_eq!(msgs[1].id.as_ref().unwrap().seqno, 16);
assert_eq!(msgs[1].leader_last_seq, 20);
// A request for the WRONG shard's stream is NOT_FOUND.
let err = service
.stream_segments(Request::new(StreamRequest {
shard_id: 9,
from_seqno: 1,
term: 0,
}))
.await
.expect_err("wrong shard must be refused");
assert_eq!(err.code(), tonic::Code::NotFound);
});
}
/// m11p4: a source whose log can never serve the range (segment format
/// unknown after a rolling upgrade / no durable WAL) surfaces as
/// `FAILED_PRECONDITION` naming the snapshot remedy — distinct from the
/// retryable `INTERNAL` a transient read failure produces.
#[test]
fn stream_segments_unavailable_is_failed_precondition() {
struct UnservableSegments;
impl crate::sources::SegmentSource for UnservableSegments {
fn source_shard(&self) -> ShardId {
ShardId(0)
}
fn stream_baseline(&self) -> u64 {
0
}
fn flushed_seq(&self) -> u64 {
100
}
fn collect_from(
&self,
_from_seq: u64,
_max_events: u64,
_max_bytes: usize,
) -> Result<Vec<SegmentChunk>, crate::sources::SegmentReadError> {
Err(crate::sources::SegmentReadError::Unavailable {
detail: "WAL segment format unknown: wal-…001.seg".into(),
})
}
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
use tokio_stream::StreamExt;
let (tx, _rx) = mpsc::channel(4);
let sources = ServingSources {
applied: None,
segments: Some(Arc::new(UnservableSegments)),
..ServingSources::default()
};
let service = WalShippingService::new(
tx,
1024,
sources,
Arc::new(Mutex::new(HashMap::new())),
Arc::new(Mutex::new(HashMap::new())),
);
let resp = service
.stream_segments(Request::new(StreamRequest {
shard_id: 0,
from_seqno: 5,
term: 0,
}))
.await
.expect("the stream opens; the failure arrives as the first message");
let mut stream = resp.into_inner();
let first = stream
.next()
.await
.expect("one error message")
.expect_err("unavailable must be an error, not a chunk");
assert_eq!(first.code(), tonic::Code::FailedPrecondition);
assert!(
first
.message()
.contains("segments not available from seq 5")
&& first.message().contains("snapshot required"),
"the status must carry the structured remedy: {}",
first.message()
);
});
}
}