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::pipeline::{AdoptedRotation, InputSlot, StreamMark, StreamMarkQueue};
use melin_transport_core::replication::protocol::{
Ack, MAX_DATA_FRAME, PrimaryMessage, decode_primary_message, try_decode_input_batch_into,
};
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;
}
pub(super) enum FrameResult {
Complete(usize, usize),
Incomplete,
Oversized,
}
pub(super) fn try_extract_frame(buf: &[u8], max_size: usize) -> FrameResult {
if buf.len() < 4 {
return FrameResult::Incomplete;
}
let len = u32::from_le_bytes(
buf[0..4]
.try_into()
.expect("bounds checked: buf has at least 4 bytes"),
) as usize;
if len == 0 || len > max_size {
return FrameResult::Oversized;
}
if buf.len() < 4 + len {
return FrameResult::Incomplete;
}
FrameResult::Complete(4, 4 + len)
}
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>>;
}
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
.lock()
.expect("stream-mark queue poisoned")
.push_back(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>),
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>,
pub observed_ack_policy: Option<u8>,
}
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,
journal_failed: &AtomicBool,
pending_acks: &mut PendingAckQueue,
) -> 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 observed_ack_policy: Option<u8> = 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 batch.push_with_or_abort(
|s| *s = slot,
|| 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(_) => match decode_primary_message(payload) {
Ok(PrimaryMessage::Heartbeat {
sequence,
ack_policy,
}) => {
debug!(sequence, ack_policy, "heartbeat from primary");
observed_ack_policy = Some(ack_policy);
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;
}
},
}
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,
observed_ack_policy,
}
}
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>>,
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];
if let Ok(()) = try_decode_input_batch_into(payload, slot_buf) {
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 batch.push_with_or_abort(
|s| *s = slot,
|| 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>),
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,
primary_ack_policy,
tip_ready: _,
primary_link_up: _,
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,
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,
journal_failed,
&mut pending_acks,
);
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));
if let Some(policy) = outcome.observed_ack_policy {
primary_ack_policy.store(policy, Ordering::Release);
}
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::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;
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,
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);
}
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) {
let mut acks = PendingAckQueue::new(16);
let outcome = process_streaming_frames::<TestEvent>(
buf,
producer,
accum_end_sequence,
slot_buf,
marks,
journal_failed,
&mut acks,
);
(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"))
}
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 {
std::sync::Arc::new(std::sync::Mutex::new(VecDeque::new()))
}
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, 1, &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);
assert_eq!(
outcome.observed_ack_policy,
Some(1),
"the heartbeat's ack policy must surface for the control-plane gauge"
);
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,
&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));
}
#[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: Vec<_> = rotations.lock().unwrap().iter().copied().collect();
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: Vec<_> = marks.lock().unwrap().iter().copied().collect();
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.lock().unwrap().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.lock().unwrap().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, 1, &mut buf);
encode_heartbeat(43, 1, &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 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,
&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,
);
}
}
#[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, 1, &mut buf);
append_input_batch_frame(&mut buf, &[slot(22, 0xE2)]);
let outcome = process_drain_frames::<TestEvent>(
&buf,
&mut producer,
19,
&mut slot_buf,
&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,
&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,
&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], 0, 1, 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());
}
}
}