use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use tracing::{debug, info};
use melin_app::AppEvent;
use melin_pipeline::wait::WaitStrategy;
use melin_transport_core::lineage::SharedLineagePolicy;
use melin_transport_core::pipeline::{AdoptedRotation, InputSlot, StreamMark, StreamMarkQueue};
use melin_transport_core::replication::protocol::{
Ack, InputBatchError, MAX_DATA_FRAME, PrimaryMessage, decode_primary_message,
try_decode_input_batch_into,
};
use melin_wire_protocol::framing::{PREFIX_LEN, split_frame_limited};
use super::{PendingAckQueue, try_flush_dual_track};
pub(super) trait ReceiverTransport {
fn poll_recv(&mut self, recv_buf: &mut Vec<u8>) -> io::Result<bool>;
fn send_ack(&mut self, ack: &Ack) -> io::Result<bool>;
fn ack_in_flight(&self) -> bool;
fn is_connected(&mut self) -> bool;
fn keep_link_serviced(&mut self) {}
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum FrameResult {
Complete(usize, usize),
Incomplete,
Oversized,
}
#[inline]
pub(super) fn try_extract_frame(buf: &[u8], max_size: usize) -> FrameResult {
match split_frame_limited(buf, max_size) {
Ok(Some(frame)) if frame.payload.is_empty() => FrameResult::Oversized,
Ok(Some(frame)) => FrameResult::Complete(PREFIX_LEN, frame.consumed()),
Ok(None) => FrameResult::Incomplete,
Err(_) => FrameResult::Oversized,
}
}
pub(super) fn compact_recv_buf(buf: &mut Vec<u8>, consumed: usize) {
if consumed > 0 {
buf.copy_within(consumed.., 0);
buf.truncate(buf.len() - consumed);
}
}
pub(super) trait ControlFrameSource {
fn next_frame(
&mut self,
max_size: usize,
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>>;
fn serviced<R: Send>(&mut self, work: impl FnOnce() -> R + Send) -> R {
work()
}
}
#[cfg(any(test, feature = "dpdk"))]
const SERVICED_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1);
#[cfg(any(test, feature = "dpdk"))]
pub(super) fn run_serviced<R: Send>(
work: impl FnOnce() -> R + Send,
mut service: impl FnMut(),
) -> R {
let work = std::sync::Mutex::new(Some(work));
let take_work = || {
work.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
.expect("serviced work is taken exactly once")
};
std::thread::scope(|scope| {
let spawned = std::thread::Builder::new()
.name("repl-serviced".into())
.spawn_scoped(scope, || {
if let Err(e) = melin_app::affinity::clear_affinity() {
tracing::warn!(error = e, "failed to unpin the replication helper thread");
}
take_work()()
});
let worker = match spawned {
Ok(worker) => worker,
Err(e) => {
tracing::warn!(
error = %e,
"could not spawn the replication helper thread — running its work \
without servicing the link; a slow one may outlast the peer's \
liveness deadline"
);
return take_work()();
}
};
while !worker.is_finished() {
service();
std::thread::sleep(SERVICED_POLL_INTERVAL);
}
match worker.join() {
Ok(r) => r,
Err(panic) => std::panic::resume_unwind(panic),
}
})
}
pub(super) fn receive_chunked_body<S: ControlFrameSource>(
source: &mut S,
tmp_path: &std::path::Path,
expected_len: u64,
what: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let result = (|| -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut tmp_file = std::fs::File::create(tmp_path)?;
let mut received: u64 = 0;
let mut running_crc: u32 = 0;
loop {
let frame = source.next_frame(MAX_DATA_FRAME)?;
match decode_primary_message(&frame)? {
PrimaryMessage::SnapshotChunk(data) => {
std::io::Write::write_all(&mut tmp_file, &data)?;
received += data.len() as u64;
running_crc = crc32c::crc32c_append(running_crc, &data);
}
PrimaryMessage::SnapshotEnd {
crc32c: expected_crc,
} => {
tmp_file.sync_all()?;
if received != expected_len {
return Err(format!(
"{what} length mismatch: expected {expected_len} bytes, got {received}"
)
.into());
}
if running_crc != expected_crc {
return Err(format!(
"{what} CRC mismatch: expected {expected_crc:#x}, got {running_crc:#x}"
)
.into());
}
return Ok(());
}
other => {
return Err(format!("expected {what} SnapshotChunk/End, got {other:?}").into());
}
}
}
})();
if result.is_err() {
let _ = std::fs::remove_file(tmp_path);
}
result
}
fn queue_stream_mark(
stream_marks: &StreamMarkQueue,
pending_accum: u64,
kind: &'static str,
mark: StreamMark,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let position = mark.sequence();
if position == pending_accum {
stream_marks.push(mark);
Ok(())
} else if position < pending_accum {
debug!(
position,
accum = pending_accum,
kind,
"stale stream mark skipped"
);
Ok(())
} else {
Err(
format!("{kind} at {position} ahead of stream position {pending_accum} — sequence gap")
.into(),
)
}
}
#[derive(Debug)]
pub(super) enum FrameError {
SequenceGap(Box<dyn std::error::Error + Send + Sync>),
Corrupted(Box<dyn std::error::Error + Send + Sync>),
Fatal(Box<dyn std::error::Error + Send + Sync>),
}
const COMMIT_EVERY: u64 = 16;
const MAX_SLOTS_PER_CYCLE: usize = 512;
pub(super) struct StreamingFrameOutcome {
pub consumed: usize,
pub any_published: bool,
pub accum_end_sequence: u64,
pub heard_from_primary: bool,
pub frame_err: Option<FrameError>,
}
fn accept_slot<E: AppEvent>(
batch: &mut melin_pipeline::ring::Batch<'_, InputSlot<E>>,
slot: InputSlot<E>,
lineage_policy: &SharedLineagePolicy,
abort: impl FnMut() -> bool,
) -> Result<u64, melin_pipeline::ring::Full> {
let change = slot.event.ack_policy_change();
let wire_seq = melin_transport_core::WireSeq::new(slot.sequence);
let index = batch.push_with_or_abort(|s| *s = slot, abort)?;
if let Some(change) = change {
lineage_policy.observe(wire_seq, change);
}
Ok(index)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn process_streaming_frames<E: AppEvent>(
recv_buf: &[u8],
input_producer: &mut melin_pipeline::ring::Producer<InputSlot<E>>,
accum_end_sequence: u64,
slot_buf: &mut Vec<InputSlot<E>>,
stream_marks: &StreamMarkQueue,
lineage_policy: &SharedLineagePolicy,
journal_failed: &AtomicBool,
pending_acks: &mut PendingAckQueue,
keep_link_serviced: &mut impl FnMut(),
) -> StreamingFrameOutcome {
let mut consumed = 0;
let mut last_target = 0u64;
let mut any_published = false;
let mut published_this_cycle = 0usize;
let mut heard_from_primary = false;
let mut frame_err: Option<FrameError> = None;
let mut batch = input_producer.batch();
let mut pending_accum = accum_end_sequence;
loop {
if journal_failed.load(Ordering::Relaxed) {
frame_err = Some(FrameError::Fatal("replica journal stage failed".into()));
break;
}
let remaining = &recv_buf[consumed..];
match try_extract_frame(remaining, MAX_DATA_FRAME) {
FrameResult::Complete(payload_start, frame_end) => {
let payload = &remaining[payload_start..frame_end];
match try_decode_input_batch_into(payload, slot_buf) {
Ok(()) => {
if !slot_buf.is_empty() {
heard_from_primary = true;
let mut frame_published = false;
for slot in slot_buf.drain(..) {
let primary_seq = slot.sequence;
if primary_seq <= pending_accum {
continue;
}
if primary_seq != pending_accum + 1 {
frame_err = Some(FrameError::SequenceGap(
format!(
"sequence gap in replication stream: \
expected {}, got {primary_seq}",
pending_accum + 1
)
.into(),
));
break;
}
match accept_slot(&mut batch, slot, lineage_policy, || {
keep_link_serviced();
journal_failed.load(Ordering::Relaxed)
}) {
Ok(index) => last_target = index + 1,
Err(_full) => {
frame_err = Some(FrameError::Fatal(
"replica journal stage failed \
(ring full mid-publish)"
.into(),
));
break;
}
}
pending_accum = primary_seq;
any_published = true;
frame_published = true;
published_this_cycle += 1;
if batch.len() >= COMMIT_EVERY {
batch.commit();
batch = input_producer.batch();
}
}
if frame_published {
batch.commit();
batch = input_producer.batch();
pending_acks.push(last_target, pending_accum);
}
if frame_err.is_some() {
break;
}
}
}
Err(InputBatchError::NotInputBatch(_)) => match decode_primary_message(payload)
{
Ok(PrimaryMessage::Heartbeat { sequence }) => {
debug!(sequence, "heartbeat from primary");
heard_from_primary = true;
}
Ok(PrimaryMessage::Rotate {
boundary_seq,
tail_hash,
}) => {
if let Err(e) = queue_stream_mark(
stream_marks,
pending_accum,
"Rotate",
StreamMark::Rotate(AdoptedRotation {
boundary_seq,
tail_hash,
}),
) {
frame_err = Some(FrameError::SequenceGap(e));
break;
}
}
Ok(PrimaryMessage::ChainCheck {
sequence,
chain_hash,
}) => {
if let Err(e) = queue_stream_mark(
stream_marks,
pending_accum,
"ChainCheck",
StreamMark::ChainCheck {
sequence,
chain_hash,
},
) {
frame_err = Some(FrameError::SequenceGap(e));
break;
}
}
Ok(PrimaryMessage::NeedSnapshot) => {
frame_err = Some(FrameError::Fatal(
"primary says we need a snapshot transfer mid-stream".into(),
));
break;
}
Ok(PrimaryMessage::HashMismatch) => {
frame_err =
Some(FrameError::Fatal("chain hash mismatch from primary".into()));
break;
}
Ok(_) => {
debug!("unexpected message during streaming");
}
Err(e) => {
frame_err = Some(FrameError::Fatal(
format!("failed to decode primary message: {e}").into(),
));
break;
}
},
Err(e) => {
slot_buf.clear();
frame_err = Some(if e.is_transit_damage() {
FrameError::Corrupted(Box::new(e))
} else {
FrameError::Fatal(Box::new(e))
});
break;
}
}
consumed += frame_end;
if published_this_cycle >= MAX_SLOTS_PER_CYCLE {
break;
}
}
FrameResult::Oversized => {
frame_err = Some(FrameError::Fatal(
"oversized frame from primary during streaming".into(),
));
break;
}
FrameResult::Incomplete => break,
}
}
batch.commit();
StreamingFrameOutcome {
consumed,
any_published,
accum_end_sequence: pending_accum,
heard_from_primary,
frame_err,
}
}
pub(super) struct DrainFrameOutcome {
pub consumed: usize,
pub last_target: u64,
pub any_published: bool,
pub accum_end_sequence: u64,
}
pub(super) fn process_drain_frames<E: AppEvent>(
recv_buf: &[u8],
input_producer: &mut melin_pipeline::ring::Producer<InputSlot<E>>,
accum_end_sequence: u64,
slot_buf: &mut Vec<InputSlot<E>>,
lineage_policy: &SharedLineagePolicy,
journal_failed: &AtomicBool,
) -> DrainFrameOutcome {
let mut consumed = 0;
let mut last_target = 0u64;
let mut any_published = false;
let mut batch = input_producer.batch();
let mut pending_accum = accum_end_sequence;
'frames: loop {
let remaining = &recv_buf[consumed..];
match try_extract_frame(remaining, MAX_DATA_FRAME) {
FrameResult::Complete(ps, fe) => {
let payload = &remaining[ps..fe];
match try_decode_input_batch_into(payload, slot_buf) {
Ok(()) => {}
Err(InputBatchError::NotInputBatch(_)) => {
consumed += fe;
continue 'frames;
}
Err(e) => {
slot_buf.clear();
if e.is_transit_damage() {
tracing::warn!(
error = %e,
"refused InputBatch in promotion drain — stopping at the \
last verified slot"
);
} else {
tracing::error!(
error = %e,
"refused InputBatch in promotion drain — stopping at the \
last verified slot"
);
}
break 'frames;
}
}
for slot in slot_buf.drain(..) {
let primary_seq = slot.sequence;
if primary_seq <= pending_accum {
continue;
}
if primary_seq != pending_accum + 1 {
tracing::warn!(
expected = pending_accum + 1,
got = primary_seq,
"sequence gap in promotion drain — stopping at the \
last contiguous slot"
);
break 'frames;
}
match accept_slot(&mut batch, slot, lineage_policy, || {
journal_failed.load(Ordering::Relaxed)
}) {
Ok(index) => last_target = index + 1,
Err(_full) => {
tracing::warn!(
"journal stage failed during promotion drain — \
stopping at the last contiguous slot"
);
break 'frames;
}
}
pending_accum = primary_seq;
any_published = true;
}
consumed += fe;
}
_ => break,
}
}
batch.commit();
DrainFrameOutcome {
consumed,
last_target,
any_published,
accum_end_sequence: pending_accum,
}
}
pub(super) enum SessionExit {
Shutdown,
Promote,
Disconnected,
StreamGap(Box<dyn std::error::Error + Send + Sync>),
Corrupted(Box<dyn std::error::Error + Send + Sync>),
Fatal(Box<dyn std::error::Error + Send + Sync>),
}
pub(super) struct StreamingResult {
pub exit: SessionExit,
pub heard_from_primary: bool,
}
#[allow(clippy::too_many_arguments)]
pub(super) fn streaming_loop<T: ReceiverTransport, E: AppEvent>(
transport: &mut T,
input_producer: &mut melin_pipeline::ring::Producer<InputSlot<E>>,
journal_cursor: &melin_pipeline::padding::Sequence,
shutdown: &AtomicBool,
control: &super::ReplicaControlPlane,
pipeline_depth: usize,
wait: WaitStrategy,
initial_sequence: u64,
mut recv_buf: Vec<u8>,
utilization: Option<&melin_transport_core::pipeline::StageUtilization>,
stream_marks: &StreamMarkQueue,
journal_failed: &AtomicBool,
) -> StreamingResult {
let super::ReplicaControlPlane {
promote,
journal_tip,
lineage_ack_policy,
tip_ready: _,
primary_link_up: _,
primary_observed: _,
pipeline_healthy: _,
} = control;
let mut slot_buf: Vec<InputSlot<E>> = Vec::new();
let mut pending_acks = PendingAckQueue::new(pipeline_depth);
let mut accum_end_sequence: u64 = initial_sequence;
let mut last_sent_acked_seq: u64 = initial_sequence;
let mut last_sent_in_memory_seq: u64 = initial_sequence;
let mut last_committed_primary_seq: u64 = initial_sequence;
journal_tip.advance(melin_transport_core::WireSeq::new(initial_sequence));
let mut heard_from_primary = false;
let mut waiter = wait.waiter();
let mut busy_count: u64 = 0;
let mut idle_count: u64 = 0;
let mut recv_offset: usize = 0;
const RECV_COMPACT_THRESHOLD: usize = 64 * 1024;
let exit = loop {
if shutdown.load(Ordering::Relaxed) {
info!("replica shutting down");
drain_pending_acks(
transport,
&mut pending_acks,
journal_cursor,
accum_end_sequence,
wait,
&mut recv_buf,
journal_failed,
);
break SessionExit::Shutdown;
}
if journal_failed.load(Ordering::Acquire) {
break SessionExit::Fatal(
"replica journal stage failed — tearing down for reconnect/resync".into(),
);
}
if promote.is_requested() {
info!("promotion triggered — stopping replication, transitioning to primary");
compact_recv_buf(&mut recv_buf, std::mem::take(&mut recv_offset));
loop {
let got_more = transport.poll_recv(&mut recv_buf).unwrap_or(false);
let outcome = process_drain_frames(
&recv_buf,
input_producer,
accum_end_sequence,
&mut slot_buf,
lineage_ack_policy,
journal_failed,
);
accum_end_sequence = outcome.accum_end_sequence;
journal_tip.advance(melin_transport_core::WireSeq::new(accum_end_sequence));
compact_recv_buf(&mut recv_buf, outcome.consumed);
if outcome.any_published {
pending_acks.push(outcome.last_target, accum_end_sequence);
}
if !got_more {
break;
}
}
drain_pending_acks(
transport,
&mut pending_acks,
journal_cursor,
accum_end_sequence,
wait,
&mut recv_buf,
journal_failed,
);
break SessionExit::Promote;
}
if !transport.ack_in_flight()
&& let Some(ack) = try_flush_dual_track(
&mut pending_acks,
journal_cursor,
accum_end_sequence,
last_sent_acked_seq,
last_sent_in_memory_seq,
)
{
debug_assert!(
ack.in_memory_sequence <= last_committed_primary_seq,
"in_memory ack ahead of committed cursor: in_memory={}, last_committed={}",
ack.in_memory_sequence,
last_committed_primary_seq,
);
match transport.send_ack(&ack) {
Ok(true) => {
last_sent_acked_seq = ack.acked_sequence;
last_sent_in_memory_seq = ack.in_memory_sequence;
}
Ok(false) => {} Err(_) => break SessionExit::Disconnected,
}
}
let any_data = match transport.poll_recv(&mut recv_buf) {
Ok(d) => d,
Err(_) => break SessionExit::Disconnected,
};
if !transport.is_connected() && recv_offset == recv_buf.len() {
drain_pending_acks(
transport,
&mut pending_acks,
journal_cursor,
accum_end_sequence,
wait,
&mut recv_buf,
journal_failed,
);
break SessionExit::Disconnected;
}
let outcome = process_streaming_frames(
&recv_buf[recv_offset..],
input_producer,
accum_end_sequence,
&mut slot_buf,
stream_marks,
lineage_ack_policy,
journal_failed,
&mut pending_acks,
&mut || transport.keep_link_serviced(),
);
accum_end_sequence = outcome.accum_end_sequence;
last_committed_primary_seq = accum_end_sequence;
journal_tip.advance(melin_transport_core::WireSeq::new(accum_end_sequence));
heard_from_primary |= outcome.heard_from_primary;
recv_offset += outcome.consumed;
if recv_offset == recv_buf.len() {
recv_buf.clear();
recv_offset = 0;
} else if recv_offset >= RECV_COMPACT_THRESHOLD {
compact_recv_buf(&mut recv_buf, recv_offset);
recv_offset = 0;
}
match outcome.frame_err {
Some(FrameError::SequenceGap(e)) => break SessionExit::StreamGap(e),
Some(FrameError::Corrupted(e)) => break SessionExit::Corrupted(e),
Some(FrameError::Fatal(e)) => break SessionExit::Fatal(e),
None => {}
}
if !any_data && !outcome.any_published {
idle_count += 1;
waiter.idle();
} else {
busy_count += 1;
waiter.reset();
}
};
if pending_acks.merged() > 0 {
debug!(
merged = pending_acks.merged(),
depth = pipeline_depth,
"replica ack queue coarsened — journal fsync ran behind the wire"
);
}
if let Some(u) = utilization {
u.busy.store(busy_count, Ordering::Relaxed);
u.idle.store(idle_count, Ordering::Relaxed);
}
StreamingResult {
exit,
heard_from_primary,
}
}
fn drain_pending_acks<T: ReceiverTransport>(
transport: &mut T,
pending_acks: &mut PendingAckQueue,
journal_cursor: &melin_pipeline::padding::Sequence,
accum_end_sequence: u64,
wait: WaitStrategy,
recv_buf: &mut Vec<u8>,
journal_failed: &AtomicBool,
) {
if let Some(seq) = pending_acks.pop_all_blocking(journal_cursor, wait, journal_failed) {
let ack = Ack {
acked_sequence: seq,
in_memory_sequence: accum_end_sequence,
};
let _ = transport.send_ack(&ack);
let mut attempts = 0u32;
while transport.ack_in_flight() && attempts < 64 {
let _ = transport.poll_recv(recv_buf);
attempts += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use melin_app::{AppEvent, CodecError};
use melin_journal::JournalEvent;
use melin_pipeline::ring::DisruptorBuilder;
use melin_transport_core::pipeline::{InputSlot, RequestId};
use melin_transport_core::replication::protocol::{encode_heartbeat, encode_input_batch};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TestEvent(u8);
impl AppEvent for TestEvent {
const MAX_ENCODED_SIZE: usize = 1;
fn encoded_size(&self) -> usize {
1
}
fn encode(&self, buf: &mut [u8]) -> usize {
buf[0] = self.0;
1
}
fn decode(buf: &[u8]) -> Result<Self, CodecError> {
Ok(TestEvent(buf[0]))
}
fn is_query(&self) -> bool {
false
}
}
fn slot(primary_seq: u64, tag: u64) -> InputSlot<TestEvent> {
InputSlot {
connection_id: 0,
request_id: RequestId(0),
key_hash: tag,
sequence: primary_seq,
timestamp_ns: 0,
event: JournalEvent::App(TestEvent(tag as u8)),
publish_ts: Default::default(),
recv_ts: Default::default(),
}
}
fn append_input_batch_frame(out: &mut Vec<u8>, slots: &[InputSlot<TestEvent>]) {
encode_input_batch(slots, out).expect("encode InputBatch");
}
fn drain(
consumer: &mut melin_pipeline::ring::Consumer<InputSlot<TestEvent>>,
) -> Vec<InputSlot<TestEvent>> {
let mut out = Vec::new();
while let Some((_seq, slot)) = consumer.try_consume() {
out.push(slot);
}
out
}
fn stream_frames(
buf: &[u8],
producer: &mut melin_pipeline::ring::Producer<InputSlot<TestEvent>>,
accum_end_sequence: u64,
slot_buf: &mut Vec<InputSlot<TestEvent>>,
marks: &StreamMarkQueue,
journal_failed: &AtomicBool,
) -> (StreamingFrameOutcome, PendingAckQueue) {
stream_frames_following(
buf,
producer,
accum_end_sequence,
slot_buf,
marks,
&SharedLineagePolicy::new(None),
journal_failed,
)
}
fn stream_frames_following(
buf: &[u8],
producer: &mut melin_pipeline::ring::Producer<InputSlot<TestEvent>>,
accum_end_sequence: u64,
slot_buf: &mut Vec<InputSlot<TestEvent>>,
marks: &StreamMarkQueue,
lineage_policy: &SharedLineagePolicy,
journal_failed: &AtomicBool,
) -> (StreamingFrameOutcome, PendingAckQueue) {
let mut acks = PendingAckQueue::new(16);
let outcome = process_streaming_frames::<TestEvent>(
buf,
producer,
accum_end_sequence,
slot_buf,
marks,
lineage_policy,
journal_failed,
&mut acks,
&mut || {},
);
(outcome, acks)
}
fn ring(
capacity: usize,
) -> (
melin_pipeline::ring::Producer<InputSlot<TestEvent>>,
melin_pipeline::ring::Consumer<InputSlot<TestEvent>>,
) {
let (producer, mut consumers) = DisruptorBuilder::<InputSlot<TestEvent>>::new(capacity)
.add_consumer()
.build(WaitStrategy::SpinThenYield);
(producer, consumers.pop().expect("consumer present"))
}
fn prefixed(len: usize, body: &[u8]) -> Vec<u8> {
let mut buf = (len as u32).to_le_bytes().to_vec();
buf.extend_from_slice(body);
buf
}
#[test]
fn extract_waits_for_the_whole_frame() {
assert_eq!(try_extract_frame(&[], 16), FrameResult::Incomplete);
assert_eq!(try_extract_frame(&[3, 0, 0], 16), FrameResult::Incomplete);
assert_eq!(
try_extract_frame(&prefixed(3, &[]), 16),
FrameResult::Incomplete
);
assert_eq!(
try_extract_frame(&prefixed(3, &[1, 2]), 16),
FrameResult::Incomplete
);
}
#[test]
fn extract_returns_payload_bounds_and_ignores_trailing_bytes() {
let buf = prefixed(3, &[1, 2, 3, 0xFF, 0xFF]);
assert_eq!(try_extract_frame(&buf, 16), FrameResult::Complete(4, 7));
}
#[test]
fn extract_admits_a_frame_at_the_limit() {
let buf = prefixed(16, &[7; 16]);
assert_eq!(try_extract_frame(&buf, 16), FrameResult::Complete(4, 20));
assert_eq!(try_extract_frame(&buf[..4], 16), FrameResult::Incomplete);
}
#[test]
fn extract_refuses_an_oversized_prefix_before_its_payload() {
assert_eq!(
try_extract_frame(&prefixed(17, &[]), 16),
FrameResult::Oversized
);
assert_eq!(
try_extract_frame(&prefixed(MAX_DATA_FRAME + 1, &[]), MAX_DATA_FRAME),
FrameResult::Oversized
);
}
#[test]
fn extract_refuses_a_zero_length_frame() {
assert_eq!(
try_extract_frame(&prefixed(0, &[1, 2, 3]), 16),
FrameResult::Oversized
);
}
use std::collections::VecDeque;
use std::sync::atomic::AtomicU64;
struct MockTransport {
recv_queue: VecDeque<Vec<u8>>,
sent_acks: Vec<Ack>,
connected: bool,
simulate_in_flight: bool,
in_flight: bool,
}
impl MockTransport {
fn new() -> Self {
Self {
recv_queue: VecDeque::new(),
sent_acks: Vec::new(),
connected: true,
simulate_in_flight: false,
in_flight: false,
}
}
fn push_data(&mut self, data: Vec<u8>) {
self.recv_queue.push_back(data);
}
fn disconnect_after_data(&mut self) {
self.connected = false;
}
}
impl ReceiverTransport for MockTransport {
fn poll_recv(&mut self, recv_buf: &mut Vec<u8>) -> io::Result<bool> {
if self.in_flight {
self.in_flight = false;
}
if let Some(chunk) = self.recv_queue.pop_front() {
recv_buf.extend_from_slice(&chunk);
Ok(true)
} else {
Ok(false)
}
}
fn send_ack(&mut self, ack: &Ack) -> io::Result<bool> {
if self.in_flight {
return Ok(false);
}
self.sent_acks.push(Ack {
acked_sequence: ack.acked_sequence,
in_memory_sequence: ack.in_memory_sequence,
});
if self.simulate_in_flight {
self.in_flight = true;
}
Ok(true)
}
fn ack_in_flight(&self) -> bool {
self.in_flight
}
fn is_connected(&mut self) -> bool {
self.connected || !self.recv_queue.is_empty()
}
}
fn journal_cursor(val: u64) -> melin_pipeline::padding::Sequence {
melin_pipeline::padding::CachePadded::new(AtomicU64::new(val))
}
fn no_marks() -> StreamMarkQueue {
melin_transport_core::pipeline::StreamMarks::shared()
}
fn control() -> crate::replication::ReplicaControlPlane {
crate::replication::ReplicaControlPlane::new()
}
fn promoted_control() -> crate::replication::ReplicaControlPlane {
let c = control();
c.promote
.request(crate::promotion::PromotionRequest::MANUAL);
c
}
#[test]
fn loop_shutdown_exits_immediately() {
let (mut producer, _consumer) = ring(16);
let cursor = journal_cursor(0);
let shutdown = AtomicBool::new(true);
let control = control();
let mut transport = MockTransport::new();
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
4,
WaitStrategy::SpinThenYield,
0,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(matches!(result.exit, SessionExit::Shutdown));
assert!(!result.heard_from_primary);
}
#[test]
fn loop_promote_drains_data_then_exits() {
let (mut producer, mut consumer) = ring(16);
let cursor = journal_cursor(u64::MAX);
let shutdown = AtomicBool::new(false);
let control = promoted_control();
let mut transport = MockTransport::new();
let mut data = Vec::new();
append_input_batch_frame(&mut data, &[slot(1, 0x01)]);
transport.push_data(data);
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
4,
WaitStrategy::SpinThenYield,
0,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(matches!(result.exit, SessionExit::Promote));
let slots = drain(&mut consumer);
assert_eq!(slots.len(), 1, "promote drain should publish pending data");
assert_eq!(slots[0].key_hash, 0x01);
}
#[test]
fn loop_disconnect_on_poll_error() {
let (mut producer, _consumer) = ring(16);
let cursor = journal_cursor(0);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
transport.disconnect_after_data();
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
4,
WaitStrategy::SpinThenYield,
0,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(matches!(result.exit, SessionExit::Disconnected));
}
#[test]
fn loop_receives_data_and_acks() {
let (mut producer, mut consumer) = ring(16);
let cursor = journal_cursor(u64::MAX);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
let mut data = Vec::new();
append_input_batch_frame(&mut data, &[slot(10, 0xA0), slot(11, 0xA1)]);
transport.push_data(data);
transport.disconnect_after_data();
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
4,
WaitStrategy::SpinThenYield,
9,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(matches!(result.exit, SessionExit::Disconnected));
assert!(result.heard_from_primary);
let slots = drain(&mut consumer);
assert_eq!(slots.len(), 2);
assert_eq!(slots[0].sequence, 10);
assert_eq!(slots[1].sequence, 11);
assert!(
!transport.sent_acks.is_empty(),
"should have sent at least one ack"
);
let last_ack = transport.sent_acks.last().unwrap();
assert_eq!(last_ack.in_memory_sequence, 11);
}
#[test]
fn loop_handles_initial_recv_buf_data() {
let (mut producer, mut consumer) = ring(16);
let cursor = journal_cursor(u64::MAX);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
transport.disconnect_after_data();
let mut initial = Vec::new();
append_input_batch_frame(&mut initial, &[slot(1, 0x42)]);
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
4,
WaitStrategy::SpinThenYield,
0,
initial,
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(result.heard_from_primary);
let slots = drain(&mut consumer);
assert_eq!(slots.len(), 1);
assert_eq!(slots[0].key_hash, 0x42);
}
#[test]
fn loop_full_ack_queue_still_acks_every_batch() {
let (mut producer, mut consumer) = ring(16);
let cursor = journal_cursor(u64::MAX);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
transport.simulate_in_flight = true;
let mut data1 = Vec::new();
append_input_batch_frame(&mut data1, &[slot(1, 0x01)]);
let mut data2 = Vec::new();
append_input_batch_frame(&mut data2, &[slot(2, 0x02)]);
transport.push_data(data1);
transport.push_data(data2);
transport.disconnect_after_data();
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
1, WaitStrategy::SpinThenYield,
0,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(matches!(result.exit, SessionExit::Disconnected));
let slots = drain(&mut consumer);
assert_eq!(slots.len(), 2, "both batches should be published");
assert!(
transport.sent_acks.len() >= 2,
"should have sent acks for both batches (got {})",
transport.sent_acks.len()
);
}
#[test]
fn loop_drains_a_burst_larger_than_one_cycle_without_losing_bytes() {
let total = (MAX_SLOTS_PER_CYCLE * 2 + 37) as u64;
let (mut producer, mut consumer) = ring(4096);
let cursor = journal_cursor(u64::MAX);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
let mut data = Vec::new();
for i in 1..=total {
append_input_batch_frame(&mut data, &[slot(i, i)]);
}
transport.push_data(data);
transport.disconnect_after_data();
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
16,
WaitStrategy::SpinThenYield,
0,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(matches!(result.exit, SessionExit::Disconnected));
let seqs: Vec<u64> = drain(&mut consumer)
.into_iter()
.map(|s| s.sequence)
.collect();
assert_eq!(seqs.len(), total as usize, "every slot published once");
assert_eq!(seqs, (1..=total).collect::<Vec<_>>(), "and in order");
assert_eq!(
transport.sent_acks.last().map(|a| a.in_memory_sequence),
Some(total)
);
}
#[test]
fn loop_keeps_receiving_while_the_journal_cursor_is_frozen() {
let (mut producer, mut consumer) = ring(16);
let cursor = journal_cursor(0);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
for i in 1..=4u64 {
let mut data = Vec::new();
append_input_batch_frame(&mut data, &[slot(i, i)]);
transport.push_data(data);
}
transport.disconnect_after_data();
std::thread::scope(|s| {
let reader = s.spawn(|| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut seen: Vec<u64> = Vec::new();
while seen.len() < 4 && std::time::Instant::now() < deadline {
seen.extend(drain(&mut consumer).into_iter().map(|sl| sl.sequence));
std::hint::spin_loop();
}
cursor.get().store(u64::MAX, Ordering::Release);
seen
});
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
1, WaitStrategy::SpinThenYield,
0,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(matches!(result.exit, SessionExit::Disconnected));
assert_eq!(
reader.join().expect("reader thread"),
vec![1, 2, 3, 4],
"every batch must be published while the cursor is frozen"
);
});
assert!(
transport
.sent_acks
.iter()
.any(|a| a.acked_sequence == 0 && a.in_memory_sequence > 0),
"in-memory acks must keep flowing while nothing is durable: {:?}",
transport.sent_acks
);
}
#[test]
fn loop_fatal_on_oversize_frame() {
let (mut producer, _consumer) = ring(16);
let cursor = journal_cursor(0);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
let mut data = Vec::new();
let oversize_len = (MAX_DATA_FRAME as u32) + 1;
data.extend_from_slice(&oversize_len.to_le_bytes());
transport.push_data(data);
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
4,
WaitStrategy::SpinThenYield,
0,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(matches!(result.exit, SessionExit::Fatal(_)));
}
#[test]
fn loop_final_ack_on_shutdown_includes_durable_sequence() {
let (mut producer, _consumer) = ring(16);
let cursor = journal_cursor(u64::MAX);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
let mut data = Vec::new();
append_input_batch_frame(&mut data, &[slot(42, 0xFF)]);
transport.push_data(data);
let mut data2 = Vec::new();
append_input_batch_frame(&mut data2, &[slot(43, 0xFE)]);
transport.push_data(data2);
let shutdown_ref = &shutdown;
std::thread::scope(|s| {
s.spawn(|| {
std::thread::sleep(std::time::Duration::from_millis(10));
shutdown_ref.store(true, Ordering::Release);
});
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
shutdown_ref,
&control,
4,
WaitStrategy::SpinThenYield,
41,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(matches!(result.exit, SessionExit::Shutdown));
});
assert!(
!transport.sent_acks.is_empty(),
"shutdown should send a final ack for durable data"
);
}
#[test]
fn loop_tracks_utilization_when_provided() {
let (mut producer, _consumer) = ring(16);
let cursor = journal_cursor(u64::MAX);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
let mut data = Vec::new();
append_input_batch_frame(&mut data, &[slot(1, 0x01)]);
transport.push_data(data);
transport.disconnect_after_data();
let utilization = melin_transport_core::pipeline::StageUtilization::new();
let _result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
4,
WaitStrategy::SpinThenYield,
0,
Vec::new(),
Some(&utilization),
&no_marks(),
&AtomicBool::new(false),
);
let busy = utilization.busy.load(Ordering::Relaxed);
let idle = utilization.idle.load(Ordering::Relaxed);
assert!(busy > 0, "should have recorded busy iterations");
assert!(busy + idle > 0, "total iterations should be non-zero");
}
#[test]
fn streaming_publishes_all_slots_and_advances_accum_end_sequence() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(6, 0xA0), slot(7, 0xA1), slot(8, 0xA2)]);
encode_heartbeat(99, &mut buf);
append_input_batch_frame(&mut buf, &[slot(9, 0xA3)]);
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
5,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_none(), "no fatal exit");
assert_eq!(outcome.consumed, buf.len(), "every byte processed");
assert!(outcome.any_published);
assert!(outcome.heard_from_primary);
assert_eq!(outcome.accum_end_sequence, 9);
let slots = drain(&mut consumer);
assert_eq!(slots.len(), 4);
let ids: Vec<u64> = slots.iter().map(|s| s.key_hash).collect();
assert_eq!(ids, vec![0xA0, 0xA1, 0xA2, 0xA3]);
}
#[test]
fn streaming_records_one_ack_target_per_frame() {
let (mut producer, _consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
for f in 0..3u64 {
append_input_batch_frame(
&mut buf,
&[slot(f * 2 + 1, f * 2 + 1), slot(f * 2 + 2, f * 2 + 2)],
);
}
let (outcome, mut q) = stream_frames(
&buf,
&mut producer,
0,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_none());
assert_eq!(outcome.accum_end_sequence, 6);
assert_eq!(q.pop_ready(&journal_cursor(2)), Some(2));
assert_eq!(q.pop_ready(&journal_cursor(4)), Some(4));
assert_eq!(q.pop_ready(&journal_cursor(6)), Some(6));
}
#[test]
fn streaming_stops_at_the_slot_cap_and_leaves_the_remainder() {
let total = MAX_SLOTS_PER_CYCLE + 100;
let (mut producer, _consumer) = ring(2048);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
for i in 1..=total as u64 {
append_input_batch_frame(&mut buf, &[slot(i, i)]);
}
let (first, _acks) = stream_frames(
&buf,
&mut producer,
0,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(first.frame_err.is_none());
assert_eq!(
first.accum_end_sequence, MAX_SLOTS_PER_CYCLE as u64,
"stops at the cap"
);
assert!(first.consumed < buf.len(), "remainder stays buffered");
let (second, _acks) = stream_frames(
&buf[first.consumed..],
&mut producer,
first.accum_end_sequence,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(second.frame_err.is_none());
assert_eq!(second.accum_end_sequence, total as u64);
assert_eq!(first.consumed + second.consumed, buf.len());
}
#[test]
fn streaming_ack_target_is_one_past_the_last_slot_index() {
let (mut producer, _consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(10, 0xA0), slot(11, 0xA1)]);
let (outcome, mut q) = stream_frames(
&buf,
&mut producer,
9,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_none());
assert_eq!(outcome.accum_end_sequence, 11);
assert_eq!(
q.pop_ready(&journal_cursor(1)),
None,
"seq 11 sits at slot index 1, which cursor 1 has not covered"
);
assert_eq!(q.pop_ready(&journal_cursor(2)), Some(11));
}
#[test]
fn drain_ack_target_is_one_past_the_last_slot_index() {
let (mut producer, _consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(1, 0x01)]);
let outcome = process_drain_frames::<TestEvent>(
&buf,
&mut producer,
0,
&mut slot_buf,
&SharedLineagePolicy::new(None),
&AtomicBool::new(false),
);
assert!(outcome.any_published);
let mut q = crate::replication::PendingAckQueue::new(4);
q.push(outcome.last_target, outcome.accum_end_sequence);
assert_eq!(
q.pop_ready(&journal_cursor(0)),
None,
"slot index 0 is not durable at cursor 0"
);
assert_eq!(q.pop_ready(&journal_cursor(1)), Some(1));
}
fn node_slot(primary_seq: u64, event: JournalEvent<TestEvent>) -> InputSlot<TestEvent> {
InputSlot {
event,
..slot(primary_seq, 0)
}
}
fn tick(primary_seq: u64) -> InputSlot<TestEvent> {
node_slot(
primary_seq,
JournalEvent::Tick {
now_ns: primary_seq,
},
)
}
fn change(primary_seq: u64, policy: crate::ack_policy::AckPolicy) -> InputSlot<TestEvent> {
node_slot(
primary_seq,
JournalEvent::AckPolicyChange(melin_journal::AckPolicyChange {
policy: policy.to_byte(),
unwitnessed: false,
}),
)
}
fn record(
policy: crate::ack_policy::AckPolicy,
wire_seq: u64,
previous: Option<crate::ack_policy::AckPolicy>,
) -> Option<melin_transport_core::lineage::LineagePolicy> {
crate::policy_control::test_support::lineage(
policy.to_byte(),
false,
wire_seq,
previous.map(|p| p.to_byte()),
)
}
#[test]
fn a_change_in_the_stream_moves_the_lineage_policy_without_a_heartbeat() {
use crate::ack_policy::AckPolicy;
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let cell = SharedLineagePolicy::new(record(AckPolicy::DiskAndRam, 1, None));
let mut buf = Vec::new();
append_input_batch_frame(
&mut buf,
&[tick(1), tick(2), change(3, AckPolicy::Disk), tick(4)],
);
let (outcome, _acks) = stream_frames_following(
&buf,
&mut producer,
0,
&mut slot_buf,
&no_marks(),
&cell,
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_none());
assert_eq!(outcome.accum_end_sequence, 4);
assert_eq!(
cell.load(),
record(AckPolicy::Disk, 3, Some(AckPolicy::DiskAndRam)),
"weakened"
);
assert_eq!(drain(&mut consumer).len(), 4, "every entry published");
}
#[test]
fn a_change_not_accepted_leaves_the_lineage_policy() {
use crate::ack_policy::AckPolicy;
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let held = record(AckPolicy::DiskAndRam, 1, None);
let cell = SharedLineagePolicy::new(held);
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[change(5, AckPolicy::Disk), tick(6)]);
let (outcome, _acks) = stream_frames_following(
&buf,
&mut producer,
5,
&mut slot_buf,
&no_marks(),
&cell,
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_none());
assert_eq!(outcome.accum_end_sequence, 6);
assert_eq!(cell.load(), held, "a duplicate is skipped");
buf.clear();
append_input_batch_frame(&mut buf, &[tick(7), change(9, AckPolicy::Disk)]);
let (outcome, _acks) = stream_frames_following(
&buf,
&mut producer,
6,
&mut slot_buf,
&no_marks(),
&cell,
&AtomicBool::new(false),
);
assert!(matches!(
outcome.frame_err,
Some(FrameError::SequenceGap(_))
));
assert_eq!(outcome.accum_end_sequence, 7);
assert_eq!(cell.load(), held, "nothing past the gap is accepted");
assert_eq!(drain(&mut consumer).len(), 2, "ticks 6 and 7 only");
}
#[test]
fn the_promotion_drain_moves_the_lineage_policy() {
use crate::ack_policy::AckPolicy;
let (mut producer, _consumer) = ring(16);
let mut slot_buf = Vec::new();
let cell = SharedLineagePolicy::new(record(AckPolicy::DiskAndRam, 1, None));
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[tick(3), change(4, AckPolicy::Disk)]);
append_input_batch_frame(&mut buf, &[tick(5)]);
let outcome = process_drain_frames::<TestEvent>(
&buf,
&mut producer,
2,
&mut slot_buf,
&cell,
&AtomicBool::new(false),
);
assert_eq!(outcome.accum_end_sequence, 5);
assert_eq!(
cell.load(),
record(AckPolicy::Disk, 4, Some(AckPolicy::DiskAndRam))
);
}
#[test]
fn streaming_rotate_at_exact_boundary_is_queued() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let rotations = no_marks();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(6, 0xE0)]);
melin_transport_core::replication::protocol::encode_rotate(6, &[0x66; 32], &mut buf);
append_input_batch_frame(&mut buf, &[slot(7, 0xE1)]);
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
5,
&mut slot_buf,
&rotations,
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_none(), "rotate at boundary is valid");
assert_eq!(outcome.accum_end_sequence, 7);
assert_eq!(drain(&mut consumer).len(), 2);
let queued = rotations.queued();
assert_eq!(queued.len(), 1);
match queued[0] {
StreamMark::Rotate(r) => {
assert_eq!(r.boundary_seq, 6);
assert_eq!(r.tail_hash, [0x66; 32]);
}
other => panic!("expected a Rotate mark, got {other:?}"),
}
}
#[test]
fn streaming_chain_check_at_exact_position_is_queued() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let marks = no_marks();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(6, 0xE0)]);
melin_transport_core::replication::protocol::encode_chain_check(6, &[0x77; 32], &mut buf);
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
5,
&mut slot_buf,
&marks,
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_none());
assert_eq!(outcome.accum_end_sequence, 6);
assert_eq!(drain(&mut consumer).len(), 1);
let queued = marks.queued();
assert_eq!(queued.len(), 1);
match queued[0] {
StreamMark::ChainCheck {
sequence,
chain_hash,
} => {
assert_eq!(sequence, 6);
assert_eq!(chain_hash, [0x77; 32]);
}
other => panic!("expected a ChainCheck mark, got {other:?}"),
}
}
#[test]
fn streaming_stale_rotate_is_dropped() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let rotations = no_marks();
let mut buf = Vec::new();
melin_transport_core::replication::protocol::encode_rotate(3, &[0x33; 32], &mut buf);
append_input_batch_frame(&mut buf, &[slot(6, 0xE0)]);
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
5,
&mut slot_buf,
&rotations,
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_none(), "stale rotate is not fatal");
assert_eq!(outcome.accum_end_sequence, 6);
assert_eq!(drain(&mut consumer).len(), 1);
assert!(rotations.queued().is_empty(), "stale rotate dropped");
}
#[test]
fn streaming_rotate_ahead_of_stream_is_a_sequence_gap() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let rotations = no_marks();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(6, 0xE0)]);
melin_transport_core::replication::protocol::encode_rotate(9, &[0x99; 32], &mut buf);
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
5,
&mut slot_buf,
&rotations,
&AtomicBool::new(false),
);
assert!(
matches!(outcome.frame_err, Some(FrameError::SequenceGap(_))),
"rotate past stream position => sequence gap"
);
assert_eq!(outcome.accum_end_sequence, 6, "prefix still committed");
assert_eq!(drain(&mut consumer).len(), 1);
assert!(rotations.queued().is_empty());
}
#[test]
fn streaming_oversize_frame_commits_prior_slots_then_fatal() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(7, 0xB0)]);
append_input_batch_frame(&mut buf, &[slot(8, 0xB1)]);
let oversize_len = (MAX_DATA_FRAME as u32) + 1;
buf.extend_from_slice(&oversize_len.to_le_bytes());
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
6,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_some(), "oversize => fatal");
assert_eq!(outcome.accum_end_sequence, 8);
assert!(outcome.any_published);
let slots = drain(&mut consumer);
assert_eq!(slots.len(), 2);
}
#[test]
fn streaming_unknown_message_after_valid_input_commits_then_fatal() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(3, 0xC0), slot(4, 0xC1)]);
buf.extend_from_slice(&1u32.to_le_bytes());
buf.push(0xFF);
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
2,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_some(), "unknown primary msg => fatal");
assert_eq!(outcome.accum_end_sequence, 4);
let slots = drain(&mut consumer);
assert_eq!(slots.len(), 2);
}
#[test]
fn streaming_partial_trailing_frame_is_incomplete_not_fatal() {
let (mut producer, mut consumer) = ring(8);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(1, 0xD0)]);
let complete_len = buf.len();
buf.extend_from_slice(&[0xDE, 0xAD, 0xBE]);
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
0,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_none());
assert_eq!(outcome.consumed, complete_len);
assert_eq!(outcome.accum_end_sequence, 1);
assert_eq!(drain(&mut consumer).len(), 1);
}
#[test]
fn streaming_heartbeat_only_does_not_advance_accum_end_sequence() {
let (mut producer, mut consumer) = ring(8);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
encode_heartbeat(42, &mut buf);
encode_heartbeat(43, &mut buf);
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
100,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_none());
assert_eq!(outcome.consumed, buf.len());
assert!(!outcome.any_published);
assert!(outcome.heard_from_primary);
assert_eq!(outcome.accum_end_sequence, 100);
assert!(drain(&mut consumer).is_empty());
}
#[test]
fn streaming_empty_buffer_is_a_noop() {
let (mut producer, mut consumer) = ring(4);
let mut slot_buf = Vec::new();
let (outcome, _acks) = stream_frames(
&[],
&mut producer,
77,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(outcome.frame_err.is_none());
assert_eq!(outcome.consumed, 0);
assert!(!outcome.any_published);
assert!(!outcome.heard_from_primary);
assert_eq!(outcome.accum_end_sequence, 77);
assert!(drain(&mut consumer).is_empty());
}
#[test]
fn a_push_waiting_on_a_full_ring_keeps_the_link_serviced() {
let (mut producer, mut consumer) = ring(4);
let mut slot_buf = Vec::new();
let slots: Vec<_> = (1..=6).map(|seq| slot(seq, seq)).collect();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &slots);
let mut serviced = 0usize;
let mut journaled = Vec::new();
let mut acks = PendingAckQueue::new(16);
let outcome = process_streaming_frames::<TestEvent>(
&buf,
&mut producer,
0,
&mut slot_buf,
&no_marks(),
&SharedLineagePolicy::new(None),
&AtomicBool::new(false),
&mut acks,
&mut || {
serviced += 1;
journaled.extend(drain(&mut consumer).into_iter().map(|s| s.sequence));
},
);
assert!(outcome.frame_err.is_none(), "{:?}", outcome.frame_err);
assert_eq!(outcome.accum_end_sequence, 6);
assert!(serviced > 0, "the full-ring wait never serviced the link");
journaled.extend(drain(&mut consumer).into_iter().map(|s| s.sequence));
assert_eq!(journaled, (1..=6).collect::<Vec<_>>());
}
#[test]
fn a_push_with_room_does_not_service_the_link() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(1, 1), slot(2, 2)]);
let mut serviced = 0usize;
let mut acks = PendingAckQueue::new(16);
let outcome = process_streaming_frames::<TestEvent>(
&buf,
&mut producer,
0,
&mut slot_buf,
&no_marks(),
&SharedLineagePolicy::new(None),
&AtomicBool::new(false),
&mut acks,
&mut || serviced += 1,
);
assert!(outcome.frame_err.is_none());
assert_eq!(serviced, 0);
assert_eq!(drain(&mut consumer).len(), 2);
}
#[test]
fn serviced_work_keeps_the_link_serviced_until_it_is_done() {
let release = AtomicBool::new(false);
let mut serviced = 0usize;
let result = run_serviced(
|| {
while !release.load(Ordering::Acquire) {
std::thread::yield_now();
}
42u32
},
|| {
serviced += 1;
if serviced == 5 {
release.store(true, Ordering::Release);
}
},
);
assert_eq!(result, 42);
assert!(serviced >= 5);
}
#[test]
fn a_panic_in_serviced_work_reaches_the_caller() {
let caught = std::panic::catch_unwind(|| {
run_serviced(|| -> u32 { panic!("install failed") }, || {})
});
let payload = caught.expect_err("the panic must propagate");
assert_eq!(payload.downcast_ref::<&str>(), Some(&"install failed"));
}
#[test]
fn the_default_serviced_runs_the_work_inline() {
struct NoFrames;
impl ControlFrameSource for NoFrames {
fn next_frame(
&mut self,
_max_size: usize,
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
Err("no frames".into())
}
}
let caller = std::thread::current().id();
let ran_on = NoFrames.serviced(|| std::thread::current().id());
assert_eq!(ran_on, caller);
}
#[test]
fn streaming_rejects_sequence_gap_across_frames() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(10, 0xA0), slot(11, 0xA1)]);
append_input_batch_frame(&mut buf, &[slot(14, 0xA2), slot(15, 0xA3)]);
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
9,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(
matches!(outcome.frame_err, Some(FrameError::SequenceGap(_))),
"a sequence gap (11 → 14) must end the session as a SequenceGap, \
not be silently accepted"
);
let published: Vec<u64> = drain(&mut consumer).iter().map(|s| s.sequence).collect();
assert_eq!(
published,
vec![10, 11],
"nothing at or beyond the gap may reach the input ring"
);
assert_eq!(
outcome.accum_end_sequence, 11,
"accum must stop at the last contiguous slot"
);
}
#[test]
fn streaming_rejects_sequence_gap_within_a_frame() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(10, 0xB0), slot(11, 0xB1), slot(13, 0xB2)]);
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
9,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(
matches!(outcome.frame_err, Some(FrameError::SequenceGap(_))),
"an intra-frame sequence gap (11 → 13) must end the session as a SequenceGap"
);
let published: Vec<u64> = drain(&mut consumer).iter().map(|s| s.sequence).collect();
assert_eq!(
published,
vec![10, 11],
"the contiguous prefix is committed; the slot past the gap is not \
(mirrors the oversize-frame semantics: commit prior progress, then stop)"
);
assert_eq!(outcome.accum_end_sequence, 11);
}
#[test]
fn streaming_skips_already_applied_slots_instead_of_reapplying() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(10, 0xC0), slot(11, 0xC1)]);
append_input_batch_frame(&mut buf, &[slot(11, 0xC1), slot(12, 0xC2)]);
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
9,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(
outcome.frame_err.is_none(),
"covered-slot re-delivery is benign overlap, not a violation: {:?}",
outcome.frame_err
);
let published: Vec<u64> = drain(&mut consumer).iter().map(|s| s.sequence).collect();
assert_eq!(
published,
vec![10, 11, 12],
"slot 11 must be applied exactly once — re-publishing rewinds the \
replica journal's sequence counter and corrupts its lineage"
);
assert_eq!(outcome.accum_end_sequence, 12);
}
#[test]
fn drain_does_not_publish_past_a_sequence_gap() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(10, 0xD0), slot(11, 0xD1)]);
append_input_batch_frame(&mut buf, &[slot(14, 0xD2)]);
let outcome = process_drain_frames::<TestEvent>(
&buf,
&mut producer,
9,
&mut slot_buf,
&SharedLineagePolicy::new(None),
&AtomicBool::new(false),
);
let published: Vec<u64> = drain(&mut consumer).iter().map(|s| s.sequence).collect();
assert_eq!(
published,
vec![10, 11],
"promotion drain must not publish slots past a sequence gap"
);
assert_eq!(outcome.accum_end_sequence, 11);
}
#[test]
fn streaming_loop_sequence_gap_ends_session_and_never_acks_past() {
let (mut producer, mut consumer) = ring(16);
let cursor = journal_cursor(u64::MAX);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
let mut data1 = Vec::new();
append_input_batch_frame(&mut data1, &[slot(1, 0x01), slot(2, 0x02)]);
transport.push_data(data1);
let mut data2 = Vec::new();
append_input_batch_frame(&mut data2, &[slot(5, 0x05), slot(6, 0x06)]);
transport.push_data(data2);
transport.disconnect_after_data();
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
4,
WaitStrategy::SpinThenYield,
0,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(
matches!(result.exit, SessionExit::StreamGap(_)),
"a gapped stream must end the session as a StreamGap (reconnect), \
not a clean exit and not a process-fatal error"
);
let published: Vec<u64> = drain(&mut consumer).iter().map(|s| s.sequence).collect();
assert_eq!(
published,
vec![1, 2],
"slots past the gap must not enter the pipeline"
);
for ack in &transport.sent_acks {
assert!(
ack.acked_sequence <= 2 && ack.in_memory_sequence <= 2,
"ack ({}, {}) names sequences past the gap at 2 — durability \
overstated for entries the replica never received",
ack.acked_sequence,
ack.in_memory_sequence,
);
}
}
fn damaged_frame(slots: &[InputSlot<TestEvent>]) -> Vec<u8> {
let mut frame = Vec::new();
append_input_batch_frame(&mut frame, slots);
let last_payload_byte = frame.len() - melin_journal::codec::CRC_SIZE - 1;
frame[last_payload_byte] ^= 0x01;
frame
}
#[test]
fn streaming_refuses_a_damaged_frame_whole() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(10, 0xA0), slot(11, 0xA1)]);
buf.extend_from_slice(&damaged_frame(&[slot(12, 0xA2), slot(13, 0xA3)]));
let (outcome, mut acks) = stream_frames(
&buf,
&mut producer,
9,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(
matches!(outcome.frame_err, Some(FrameError::Corrupted(_))),
"a damaged slot must end the session as Corrupted (reconnect): {:?}",
outcome.frame_err
);
let published: Vec<u64> = drain(&mut consumer).iter().map(|s| s.sequence).collect();
assert_eq!(
published,
vec![10, 11],
"nothing from the damaged frame — not even its intact slot — may \
reach the input ring"
);
assert_eq!(outcome.accum_end_sequence, 11, "in-memory ack stops at 11");
assert_eq!(
acks.pop_all_blocking(
&journal_cursor(u64::MAX),
WaitStrategy::SpinThenYield,
&AtomicBool::new(false)
),
Some(11),
"no persisted-ack target may name the damaged frame"
);
}
#[test]
fn streaming_refuses_a_frame_with_damaged_framing_and_reconnects() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(10, 0xA0)]);
let damaged_at = buf.len();
append_input_batch_frame(&mut buf, &[slot(11, 0xA1)]);
buf[damaged_at + 5..damaged_at + 7].copy_from_slice(&2u16.to_le_bytes());
let (outcome, _acks) = stream_frames(
&buf,
&mut producer,
9,
&mut slot_buf,
&no_marks(),
&AtomicBool::new(false),
);
assert!(
matches!(outcome.frame_err, Some(FrameError::Corrupted(_))),
"framing damage must end the session as Corrupted (reconnect): {:?}",
outcome.frame_err
);
let published: Vec<u64> = drain(&mut consumer).iter().map(|s| s.sequence).collect();
assert_eq!(published, vec![10], "nothing from the damaged frame");
assert_eq!(outcome.accum_end_sequence, 10);
}
#[test]
fn streaming_loop_damaged_entry_ends_session_and_is_never_acked() {
let (mut producer, mut consumer) = ring(16);
let cursor = journal_cursor(u64::MAX);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
let mut data1 = Vec::new();
append_input_batch_frame(&mut data1, &[slot(1, 0x01), slot(2, 0x02)]);
transport.push_data(data1);
transport.push_data(damaged_frame(&[slot(3, 0x03)]));
let mut data3 = Vec::new();
append_input_batch_frame(&mut data3, &[slot(4, 0x04)]);
transport.push_data(data3);
transport.disconnect_after_data();
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
4,
WaitStrategy::SpinThenYield,
0,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(
matches!(result.exit, SessionExit::Corrupted(_)),
"a damaged entry must end the session as Corrupted (reconnect)"
);
let published: Vec<u64> = drain(&mut consumer).iter().map(|s| s.sequence).collect();
assert_eq!(published, vec![1, 2]);
for ack in &transport.sent_acks {
assert!(
ack.acked_sequence <= 2 && ack.in_memory_sequence <= 2,
"ack ({}, {}) names the damaged entry",
ack.acked_sequence,
ack.in_memory_sequence,
);
}
}
#[test]
fn drain_stops_at_a_damaged_frame() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(10, 0xD0)]);
buf.extend_from_slice(&damaged_frame(&[slot(11, 0xD1)]));
append_input_batch_frame(&mut buf, &[slot(12, 0xD2)]);
let outcome = process_drain_frames::<TestEvent>(
&buf,
&mut producer,
9,
&mut slot_buf,
&SharedLineagePolicy::new(None),
&AtomicBool::new(false),
);
let published: Vec<u64> = drain(&mut consumer).iter().map(|s| s.sequence).collect();
assert_eq!(published, vec![10]);
assert_eq!(outcome.accum_end_sequence, 10);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LossyEvent(u8);
impl AppEvent for LossyEvent {
const MAX_ENCODED_SIZE: usize = 1;
fn encoded_size(&self) -> usize {
1
}
fn encode(&self, buf: &mut [u8]) -> usize {
buf[0] = self.0;
1
}
fn decode(buf: &[u8]) -> Result<Self, CodecError> {
Ok(LossyEvent(buf[0] & 0x7F))
}
fn is_query(&self) -> bool {
false
}
}
#[test]
fn streaming_codec_that_does_not_round_trip_is_fatal() {
let (mut producer, mut consumers) = DisruptorBuilder::<InputSlot<LossyEvent>>::new(16)
.add_consumer()
.build(WaitStrategy::SpinThenYield);
let mut consumer = consumers.pop().expect("consumer present");
let mut slot_buf = Vec::new();
let lossy = |seq: u64, v: u8| InputSlot {
connection_id: 0,
request_id: RequestId(0),
key_hash: 0,
sequence: seq,
timestamp_ns: 0,
event: JournalEvent::App(LossyEvent(v)),
publish_ts: Default::default(),
recv_ts: Default::default(),
};
let mut buf = Vec::new();
encode_input_batch(&[lossy(1, 0x10)], &mut buf).expect("encode");
encode_input_batch(&[lossy(2, 0x90)], &mut buf).expect("encode");
let mut acks = PendingAckQueue::new(16);
let outcome = process_streaming_frames::<LossyEvent>(
&buf,
&mut producer,
0,
&mut slot_buf,
&no_marks(),
&SharedLineagePolicy::new(None),
&AtomicBool::new(false),
&mut acks,
&mut || {},
);
match &outcome.frame_err {
Some(FrameError::Fatal(e)) => assert!(
e.to_string()
.contains("does not survive the application codec"),
"{e}"
),
other => panic!("expected a fatal codec error, got {other:?}"),
}
let mut published = Vec::new();
while let Some((_seq, slot)) = consumer.try_consume() {
published.push(slot.sequence);
}
assert_eq!(published, vec![1], "the entry that round-trips is accepted");
assert_eq!(outcome.accum_end_sequence, 1);
}
#[test]
fn streaming_loop_anchors_contiguity_at_the_resume_point() {
let (mut producer, mut consumer) = ring(16);
let cursor = journal_cursor(u64::MAX);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
let mut data = Vec::new();
append_input_batch_frame(&mut data, &[slot(102, 0x66), slot(103, 0x67)]);
transport.push_data(data);
transport.disconnect_after_data();
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
4,
WaitStrategy::SpinThenYield,
100, Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(
matches!(result.exit, SessionExit::StreamGap(_)),
"a first slot past resume_point+1 must end the session as a gap — \
the gate is not anchored at the resume point"
);
assert!(
drain(&mut consumer).is_empty(),
"nothing may be published when the first slot is already a gap"
);
for ack in &transport.sent_acks {
assert!(
ack.acked_sequence <= 100 && ack.in_memory_sequence <= 100,
"ack ({}, {}) names a sequence past the resume point 100",
ack.acked_sequence,
ack.in_memory_sequence,
);
}
}
#[test]
fn streaming_loop_accepts_contiguous_stream_from_resume_point() {
let (mut producer, mut consumer) = ring(16);
let cursor = journal_cursor(u64::MAX);
let shutdown = AtomicBool::new(false);
let control = control();
let mut transport = MockTransport::new();
let mut data = Vec::new();
append_input_batch_frame(&mut data, &[slot(101, 0x70), slot(102, 0x71)]);
transport.push_data(data);
transport.disconnect_after_data();
let result = streaming_loop::<MockTransport, TestEvent>(
&mut transport,
&mut producer,
&cursor,
&shutdown,
&control,
4,
WaitStrategy::SpinThenYield,
100,
Vec::new(),
None,
&no_marks(),
&AtomicBool::new(false),
);
assert!(
matches!(result.exit, SessionExit::Disconnected),
"a contiguous post-resume stream must not be fatal: {:?}",
matches!(result.exit, SessionExit::Fatal(_))
);
let published: Vec<u64> = drain(&mut consumer).iter().map(|s| s.sequence).collect();
assert_eq!(published, vec![101, 102]);
let last = transport
.sent_acks
.last()
.expect("a contiguous stream must produce an ack");
assert_eq!(
last.in_memory_sequence, 102,
"ack must advance to the last contiguous slot from the resume point"
);
}
#[test]
fn drain_skips_non_input_frames_and_publishes_every_input_batch() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(20, 0xE0), slot(21, 0xE1)]);
encode_heartbeat(999, &mut buf);
append_input_batch_frame(&mut buf, &[slot(22, 0xE2)]);
let outcome = process_drain_frames::<TestEvent>(
&buf,
&mut producer,
19,
&mut slot_buf,
&SharedLineagePolicy::new(None),
&AtomicBool::new(false),
);
assert!(outcome.any_published);
assert_eq!(outcome.consumed, buf.len());
assert_eq!(outcome.accum_end_sequence, 22);
let slots = drain(&mut consumer);
let ids: Vec<u64> = slots.iter().map(|s| s.key_hash).collect();
assert_eq!(ids, vec![0xE0, 0xE1, 0xE2]);
}
#[test]
fn drain_stops_at_incomplete_trailing_frame() {
let (mut producer, mut consumer) = ring(16);
let mut slot_buf = Vec::new();
let mut buf = Vec::new();
append_input_batch_frame(&mut buf, &[slot(50, 0xF0)]);
let complete_len = buf.len();
buf.extend_from_slice(&[0xDE, 0xAD]);
let outcome = process_drain_frames::<TestEvent>(
&buf,
&mut producer,
49,
&mut slot_buf,
&SharedLineagePolicy::new(None),
&AtomicBool::new(false),
);
assert_eq!(outcome.consumed, complete_len);
assert_eq!(outcome.accum_end_sequence, 50);
assert_eq!(drain(&mut consumer).len(), 1);
}
#[test]
fn drain_empty_buffer_is_a_noop() {
let (mut producer, mut consumer) = ring(4);
let mut slot_buf = Vec::new();
let outcome = process_drain_frames::<TestEvent>(
&[],
&mut producer,
55,
&mut slot_buf,
&SharedLineagePolicy::new(None),
&AtomicBool::new(false),
);
assert_eq!(outcome.consumed, 0);
assert!(!outcome.any_published);
assert_eq!(outcome.accum_end_sequence, 55);
assert!(drain(&mut consumer).is_empty());
}
mod chunked_body {
use super::super::{ControlFrameSource, receive_chunked_body};
use melin_transport_core::replication::protocol::{
encode_snapshot_chunk, encode_snapshot_end, encode_stream_start,
};
use std::collections::VecDeque;
struct Scripted {
frames: VecDeque<Vec<u8>>,
}
impl ControlFrameSource for Scripted {
fn next_frame(
&mut self,
_max_size: usize,
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
self.frames
.pop_front()
.ok_or_else(|| "disconnected during transfer".into())
}
}
fn payload(mut encode: impl FnMut(&mut Vec<u8>)) -> Vec<u8> {
let mut buf = Vec::new();
encode(&mut buf);
buf[4..].to_vec()
}
fn chunk(data: &[u8]) -> Vec<u8> {
payload(|b| encode_snapshot_chunk(data, b))
}
fn end(crc: u32) -> Vec<u8> {
payload(|b| encode_snapshot_end(crc, b))
}
fn source(frames: Vec<Vec<u8>>) -> Scripted {
Scripted {
frames: frames.into(),
}
}
#[test]
fn happy_path_writes_body_and_verifies() {
let dir = tempfile::tempdir().unwrap();
let tmp = dir.path().join("body.tmp");
let body = b"hello, replica seed bytes";
let crc = crc32c::crc32c(body);
let mut src = source(vec![chunk(&body[..10]), chunk(&body[10..]), end(crc)]);
receive_chunked_body(&mut src, &tmp, body.len() as u64, "snapshot").unwrap();
assert_eq!(std::fs::read(&tmp).unwrap(), body);
}
#[test]
fn length_mismatch_errs_and_removes_tmp() {
let dir = tempfile::tempdir().unwrap();
let tmp = dir.path().join("body.tmp");
let body = b"twelve bytes";
let crc = crc32c::crc32c(body);
let mut src = source(vec![chunk(body), end(crc)]);
let err = receive_chunked_body(&mut src, &tmp, body.len() as u64 + 1, "snapshot")
.unwrap_err();
assert!(err.to_string().contains("length mismatch"), "{err}");
assert!(!tmp.exists(), "partial tmp must be removed");
}
#[test]
fn crc_mismatch_errs_and_removes_tmp() {
let dir = tempfile::tempdir().unwrap();
let tmp = dir.path().join("body.tmp");
let body = b"twelve bytes";
let mut src = source(vec![chunk(body), end(0xDEAD_BEEF)]);
let err =
receive_chunked_body(&mut src, &tmp, body.len() as u64, "snapshot").unwrap_err();
assert!(err.to_string().contains("CRC mismatch"), "{err}");
assert!(!tmp.exists());
}
#[test]
fn disconnect_before_end_errs_and_removes_tmp() {
let dir = tempfile::tempdir().unwrap();
let tmp = dir.path().join("body.tmp");
let mut src = source(vec![chunk(b"partial")]);
let err = receive_chunked_body(&mut src, &tmp, 7, "segment seed").unwrap_err();
assert!(err.to_string().contains("disconnected"), "{err}");
assert!(!tmp.exists());
}
#[test]
fn unexpected_frame_errs_and_removes_tmp() {
let dir = tempfile::tempdir().unwrap();
let tmp = dir.path().join("body.tmp");
let stray = payload(|b| encode_stream_start(0, 1, [0u8; 32], Some(0), 0, b));
let mut src = source(vec![stray]);
let err = receive_chunked_body(&mut src, &tmp, 4, "snapshot").unwrap_err();
assert!(err.to_string().contains("SnapshotChunk/End"), "{err}");
assert!(!tmp.exists());
}
}
}