blob: 131831aaafc83d0d4edbe68b53c96af4ada87783 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use iggy_binary_protocol::{Operation, PrepareHeader};
use journal::{Journal, Storage};
use server_common::{
iobuf::{Frozen, Owned},
send_messages2::{COMMAND_HEADER_SIZE, SendMessages2Ref, decode_prepare_slice},
};
use std::io;
use std::{
cell::UnsafeCell,
collections::{BTreeMap, HashMap},
};
use tracing::warn;
use crate::{Fragment, PollFragments, PollQueryResult};
const ZERO_LEN: usize = 0;
const PREPARE_HEADER_SIZE: usize = std::mem::size_of::<PrepareHeader>();
type JournalBuffer = Frozen<4096>;
/// Decoded `SendMessages` header fields surfaced from a journal (re-)append so a
/// caller can fold segment accounting without a second decode of the same bytes.
/// Raw header values only: the journal stays agnostic of partition-layer
/// accounting types (`JournalInfo` lives in the log layer). `None` is surfaced
/// for non-`SendMessages` ops, which carry no segment bytes.
#[derive(Clone, Copy)]
pub struct RetainedBatchMeta {
pub base_offset: u64,
pub base_timestamp: u64,
pub total_size: u64,
pub message_count: u32,
}
/// Lookup key for querying messages from the journal.
///
/// `ceiling` is the inclusive commit-frontier bound: the resident journal holds
/// replicated-but-uncommitted prepares (a pipeline ahead of the commit
/// frontier), so a poll must never return a message past `ceiling` or it leaks
/// a dirty read of view-change-rollbackable data.
#[derive(Debug, Clone, Copy)]
pub enum MessageLookup {
Offset {
offset: u64,
count: u32,
ceiling: u64,
},
Timestamp {
timestamp: u64,
count: u32,
ceiling: u64,
},
}
impl MessageLookup {
pub const fn count(self) -> u32 {
match self {
Self::Offset { count, .. } | Self::Timestamp { count, .. } => count,
}
}
/// Inclusive commit-frontier upper bound: no message with a greater offset
/// may be served (uncommitted, rollbackable on a view change).
pub const fn ceiling(self) -> u64 {
match self {
Self::Offset { ceiling, .. } | Self::Timestamp { ceiling, .. } => ceiling,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct SelectedBatchSlice {
pub start: usize,
pub end: usize,
pub matched_messages: u32,
pub last_matching_offset: u64,
}
/// In-memory only partition journal storage. Non-durable.
///
/// # Warning — development storage only
///
/// This storage backs the `Journal` trait with a plain `Vec<JournalBuffer>`
/// inside an `UnsafeCell`. Writes never hit disk, nothing is `fsync`ed, and
/// every entry is lost on process exit.
///
/// That property breaks VSR invariants in two visible ways once a cluster
/// is running real workloads:
///
/// - `VsrAction::RetransmitPrepares` (see `shard::IggyShard::apply_actions`)
/// reads from this journal. After a node restart the journal is empty, so
/// the retransmit is a silent no-op and peers waiting on the missing ops
/// stall until a view change kicks in.
/// - A restarting replica that rejoins the cluster cannot replay its WAL
/// to catch up; it looks to peers like a pristine empty node claiming
/// the replica slot.
///
/// These are safe for single-process tests, the simulator, and local dev
/// workloads. They are NOT safe for any multi-process or restart-sensitive
/// deployment. Use a disk-backed `Storage` implementation before serving
/// production cluster traffic.
#[derive(Debug, Default)]
pub struct PartitionJournalMemStorage {
entries: UnsafeCell<Vec<JournalBuffer>>,
/// Maps byte offset (as if disk-backed) to index in entries Vec
offset_to_index: UnsafeCell<HashMap<usize, usize>>,
/// Current write position (cumulative byte offset)
current_offset: UnsafeCell<usize>,
}
impl Storage for PartitionJournalMemStorage {
type Buffer = JournalBuffer;
async fn write_at(&self, _offset: usize, buf: Self::Buffer) -> io::Result<usize> {
let len = buf.len();
let entries = unsafe { &mut *self.entries.get() };
let offset_to_index = unsafe { &mut *self.offset_to_index.get() };
let current_offset = unsafe { &mut *self.current_offset.get() };
let index = entries.len();
offset_to_index.insert(*current_offset, index);
entries.push(buf);
*current_offset += len;
Ok(len)
}
async fn read_at(&self, offset: usize, _buffer: Self::Buffer) -> io::Result<Self::Buffer> {
let offset_to_index = unsafe { &*self.offset_to_index.get() };
let Some(&index) = offset_to_index.get(&offset) else {
return Ok(Owned::<4096>::zeroed(0).into());
};
let entries = unsafe { &*self.entries.get() };
Ok(entries
.get(index)
.cloned()
.unwrap_or_else(|| Owned::<4096>::zeroed(0).into()))
}
}
pub struct PartitionJournal<S>
where
S: Storage<Buffer = JournalBuffer>,
{
/// Maps op -> storage byte offset (for all entries)
op_to_storage_offset: UnsafeCell<BTreeMap<u64, usize>>,
/// Maps message offset -> op (for queryable entries)
offset_to_op: UnsafeCell<BTreeMap<u64, u64>>,
/// Maps `(origin_timestamp, op)` -> op (for queryable entries).
///
/// Keeping `op` in the key preserves duplicate timestamps while still
/// letting us seek to the closest batch for timestamp-based polling.
timestamp_to_op: UnsafeCell<BTreeMap<(u64, u64), u64>>,
headers: UnsafeCell<Vec<PrepareHeader>>,
inner: UnsafeCell<JournalInner<S>>,
}
impl<S> Default for PartitionJournal<S>
where
S: Storage<Buffer = JournalBuffer> + Default,
{
fn default() -> Self {
Self {
op_to_storage_offset: UnsafeCell::new(BTreeMap::new()),
offset_to_op: UnsafeCell::new(BTreeMap::new()),
timestamp_to_op: UnsafeCell::new(BTreeMap::new()),
headers: UnsafeCell::new(Vec::new()),
inner: UnsafeCell::new(JournalInner {
storage: S::default(),
}),
}
}
}
impl<S> std::fmt::Debug for PartitionJournal<S>
where
S: Storage<Buffer = JournalBuffer>,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PartitionJournal2Impl").finish()
}
}
struct JournalInner<S>
where
S: Storage<Buffer = JournalBuffer>,
{
storage: S,
}
impl PartitionJournalMemStorage {
/// Synchronous mirror of [`Storage::read_at`] for the poll path. Mem
/// storage never hits the reactor (it copies from an in-memory `Vec`), so
/// the read can run under a partition borrow without crossing an `.await`
/// - the property that keeps poll-read sound.
fn read_at_sync(&self, offset: usize) -> JournalBuffer {
let offset_to_index = unsafe { &*self.offset_to_index.get() };
let Some(&index) = offset_to_index.get(&offset) else {
return Owned::<4096>::zeroed(0).into();
};
let entries = unsafe { &*self.entries.get() };
entries
.get(index)
.cloned()
.unwrap_or_else(|| Owned::<4096>::zeroed(0).into())
}
fn entries(&self) -> Vec<JournalBuffer> {
let entries = unsafe { &*self.entries.get() };
entries.clone()
}
fn drain(&self) -> Vec<JournalBuffer> {
let entries = unsafe { &mut *self.entries.get() };
let offset_to_index = unsafe { &mut *self.offset_to_index.get() };
let current_offset = unsafe { &mut *self.current_offset.get() };
offset_to_index.clear();
*current_offset = 0;
std::mem::take(entries)
}
fn is_empty(&self) -> bool {
let entries = unsafe { &*self.entries.get() };
entries.is_empty()
}
fn current_offset(&self) -> usize {
let current_offset = unsafe { &*self.current_offset.get() };
*current_offset
}
}
impl PartitionJournal<PartitionJournalMemStorage> {
/// Synchronous resident-range poll read. Never awaits (mem storage reads
/// are pure memory copies), so a partition borrow held across it cannot span
/// a scheduler yield. The poll path uses this; the disk tier, which does
/// await file IO, runs off the borrow on owned descriptors.
pub fn get_sync(&self, query: &MessageLookup) -> Option<PollQueryResult<4096>> {
let query = *query;
let start_op = self.candidate_start_op(&query)?;
let result = self.load_polled_batches_from_storage_sync(start_op, query);
(!result.0.is_empty()).then_some(result)
}
fn load_polled_batches_from_storage_sync(
&self,
start_op: u64,
query: MessageLookup,
) -> PollQueryResult<4096> {
let count = query.count();
if count == 0 {
return (PollFragments::new(), None);
}
// Disjoint `UnsafeCell`s: this borrows `op_to_storage_offset` while the
// loop borrows `inner.storage` (via `read_at_sync`); the loop mutates
// neither, so iterating the range in place avoids a per-poll Vec copy.
let op_to_storage_offset = unsafe { &*self.op_to_storage_offset.get() };
let mut fragments = PollFragments::new();
let mut last_matching_offset = None;
let mut matched_messages = 0u32;
for (_, &storage_offset) in op_to_storage_offset.range(start_op..) {
if matched_messages >= count {
break;
}
let bytes = {
let inner = unsafe { &*self.inner.get() };
inner.storage.read_at_sync(storage_offset)
};
try_push_resident_entry(
&bytes,
query,
&mut fragments,
&mut last_matching_offset,
&mut matched_messages,
);
}
(fragments, last_matching_offset)
}
/// Drain all accumulated batches, matching the legacy `PartitionJournal` API.
pub fn commit(&self) -> Vec<JournalBuffer> {
let entries = {
let inner = unsafe { &*self.inner.get() };
inner.storage.drain()
};
let headers = unsafe { &mut *self.headers.get() };
headers.clear();
let op_to_storage_offset = unsafe { &mut *self.op_to_storage_offset.get() };
op_to_storage_offset.clear();
let offset_to_op = unsafe { &mut *self.offset_to_op.get() };
offset_to_op.clear();
let timestamp_to_op = unsafe { &mut *self.timestamp_to_op.get() };
timestamp_to_op.clear();
entries
}
/// Entries forming the contiguous committed op-run from the front of the
/// journal up to and including `commit_max`, WITHOUT evicting them.
///
/// A backup journals replicated prepares up to a full pipeline ahead of the
/// commit frontier. Only this gapless prefix may be flushed to a segment;
/// persisting the uncommitted tail would write per-replica-timing bytes to
/// disk (cross-replica divergence) and drop the headers those ops need when
/// their own commit later lands (`commit_min` wedge). Stopping at the first
/// gap keeps a post-gap op (even one `<= commit_max`) resident until its
/// predecessor lands, so nothing is persisted ahead of a replication hole.
/// Entries are append-ordered, op-ascending on a backup, so the prefix is
/// the front. Read-only: the caller evicts via `evict_prefix` only once the
/// bytes are durable, so a persist failure leaves the prefix recoverable.
pub fn committed_prefix(&self, commit_max: u64) -> Vec<JournalBuffer> {
let headers = unsafe { &*self.headers.get() };
let entries = {
let inner = unsafe { &*self.inner.get() };
inner.storage.entries()
};
let mut committed = Vec::new();
let mut expected: Option<u64> = None;
for (header, entry) in headers.iter().zip(entries) {
let contiguous = expected.is_none_or(|next| header.op == next);
if header.op > commit_max || !contiguous {
break;
}
expected = Some(header.op + 1);
committed.push(entry);
}
committed
}
/// Evict the first `count` entries (the committed prefix just read via
/// `committed_prefix`) and keep the rest resident with the op / offset /
/// timestamp indexes rebuilt for the compacted layout. Returns each retained
/// entry paired with its `RetainedBatchMeta`, surfaced from the re-append
/// decode, so the caller folds its accounting without decoding the tail a
/// second time. Re-appending replays the original bytes, valid when first
/// appended, so it cannot fail. Call only after the evicted bytes are
/// durable: on a persist failure the prefix must stay resident for recovery.
pub async fn evict_prefix(
&self,
count: usize,
) -> Vec<(JournalBuffer, Option<RetainedBatchMeta>)> {
let all_entries = {
let inner = unsafe { &*self.inner.get() };
inner.storage.drain()
};
{
let headers = unsafe { &mut *self.headers.get() };
headers.clear();
let op_to_storage_offset = unsafe { &mut *self.op_to_storage_offset.get() };
op_to_storage_offset.clear();
let offset_to_op = unsafe { &mut *self.offset_to_op.get() };
offset_to_op.clear();
let timestamp_to_op = unsafe { &mut *self.timestamp_to_op.get() };
timestamp_to_op.clear();
}
let retained: Vec<JournalBuffer> = all_entries.into_iter().skip(count).collect();
let mut result = Vec::with_capacity(retained.len());
for entry in retained {
let meta = self
.append_with_meta(entry.clone())
.await
.expect("re-appending a retained journal entry must not fail");
result.push((entry, meta));
}
result
}
/// `append`, additionally returning the decoded `RetainedBatchMeta` for a
/// `SendMessages` entry so the eviction path folds its accounting without a
/// second decode of the same bytes.
///
/// INVARIANT (length-lock): the header is pushed before `storage.write_at`,
/// so `headers[i]` and the entry at storage index `i` stay positionally
/// paired - `committed_prefix`'s zip relies on that. `MemStorage::write_at`
/// is infallible, so the push never runs ahead of a failed write. A future
/// fallible `Storage` MUST roll the header push back on a write error (or
/// write before pushing the header) or the zip desyncs.
async fn append_with_meta(
&self,
entry: JournalBuffer,
) -> io::Result<Option<RetainedBatchMeta>> {
let header_bytes = &entry[..PREPARE_HEADER_SIZE];
let header = *bytemuck::checked::try_from_bytes::<PrepareHeader>(header_bytes)
.expect("partition journal append expects a valid prepare header");
let op = header.op;
// One decode feeds both the offset/timestamp index (keyed on
// `origin_timestamp`) and the surfaced accounting meta (`base_timestamp`,
// size, count); the two timestamps are distinct fields, do not conflate.
let (index_offset_timestamp, meta) = if header.operation == Operation::SendMessages {
match decode_prepare_slice(entry.as_slice()) {
Ok(batch) if batch.message_count() != 0 => {
let message_count = batch.message_count();
let meta = RetainedBatchMeta {
base_offset: batch.header.base_offset,
base_timestamp: batch.header.base_timestamp,
total_size: batch.header.total_size() as u64,
message_count,
};
(
Some((batch.header.base_offset, batch.header.origin_timestamp)),
Some(meta),
)
}
_ => (None, None),
}
} else {
(None, None)
};
{
let headers = unsafe { &mut *self.headers.get() };
headers.push(header);
};
let storage_offset = {
let inner = unsafe { &*self.inner.get() };
let storage_offset = inner.storage.current_offset();
inner.storage.write_at(storage_offset, entry).await?;
storage_offset
};
{
let op_to_storage_offset = unsafe { &mut *self.op_to_storage_offset.get() };
op_to_storage_offset.insert(op, storage_offset);
}
if let Some((offset, timestamp)) = index_offset_timestamp {
let offset_to_op = unsafe { &mut *self.offset_to_op.get() };
offset_to_op.insert(offset, op);
let timestamp_to_op = unsafe { &mut *self.timestamp_to_op.get() };
timestamp_to_op.insert((timestamp, op), op);
}
Ok(meta)
}
pub fn is_empty(&self) -> bool {
let inner = unsafe { &*self.inner.get() };
inner.storage.is_empty()
}
/// Owned, op-ascending clones of every resident journal entry. Each clone
/// is a `Frozen` refcount bump, not a deep copy. Used to snapshot the
/// resident tail at poll-plan time so a disk-tier straddle can be spliced
/// off the partition borrow on owned data ([`crate::iggy_partition`]).
pub fn resident_entries(&self) -> Vec<JournalBuffer> {
let inner = unsafe { &*self.inner.get() };
inner.storage.entries()
}
}
impl<S> PartitionJournal<S>
where
S: Storage<Buffer = JournalBuffer>,
{
#[must_use]
pub const fn with_storage(storage: S) -> Self {
Self {
op_to_storage_offset: UnsafeCell::new(BTreeMap::new()),
offset_to_op: UnsafeCell::new(BTreeMap::new()),
timestamp_to_op: UnsafeCell::new(BTreeMap::new()),
headers: UnsafeCell::new(Vec::new()),
inner: UnsafeCell::new(JournalInner { storage }),
}
}
pub fn header_by_op(&self, op: u64) -> Option<PrepareHeader> {
let headers = unsafe { &*self.headers.get() };
headers.iter().find(|header| header.op == op).copied()
}
/// Headers for the contiguous op run `from_op ..= commit_max`, in op order,
/// stopping at the first missing op. A replication gap must not be skipped:
/// the caller advances `commit_min` strictly by one, so a hole would break
/// that contract. Headers are append-ordered, which is op-ascending on a
/// backup, so this is a single linear scan: drop ops below `from_op`, take
/// while contiguous, stop at the first gap or past `commit_max`.
pub fn committed_headers_from(&self, from_op: u64, commit_max: u64) -> Vec<PrepareHeader> {
let headers = unsafe { &*self.headers.get() };
let mut result = Vec::new();
let mut expected = from_op;
for header in headers {
if header.op < from_op {
continue;
}
if header.op != expected || header.op > commit_max {
break;
}
result.push(*header);
expected += 1;
}
result
}
/// Oldest message offset still resident in the in-memory journal, if
/// any. Polls below this must fall back to the on-disk segments.
pub fn oldest_resident_offset(&self) -> Option<u64> {
let offset_to_op = unsafe { &*self.offset_to_op.get() };
offset_to_op.keys().next().copied()
}
fn candidate_start_op(&self, query: &MessageLookup) -> Option<u64> {
match query {
MessageLookup::Offset { offset, .. } => {
let offset_to_op = unsafe { &*self.offset_to_op.get() };
offset_to_op
.range(..=*offset)
.next_back()
.or_else(|| offset_to_op.range(*offset..).next())
.map(|(_, op)| *op)
}
MessageLookup::Timestamp { timestamp, .. } => {
let timestamp_to_op = unsafe { &*self.timestamp_to_op.get() };
let next_at_or_after = timestamp_to_op
.range((*timestamp, 0)..)
.next()
.map(|(key, op)| (*key, *op));
if let Some(((candidate_timestamp, _), op)) = next_at_or_after
&& candidate_timestamp == *timestamp
{
return Some(op);
}
timestamp_to_op
.range(..(*timestamp, 0))
.next_back()
.map(|(_, op)| *op)
.or_else(|| next_at_or_after.map(|(_, op)| op))
}
}
}
async fn bytes_by_op(&self, op: u64) -> Option<JournalBuffer> {
let storage_offset = {
let op_to_storage_offset = unsafe { &*self.op_to_storage_offset.get() };
*op_to_storage_offset.get(&op)?
};
let bytes = {
let inner = unsafe { &*self.inner.get() };
inner
.storage
.read_at(storage_offset, Owned::<4096>::zeroed(ZERO_LEN).into())
.await
.unwrap_or_else(|_| Owned::<4096>::zeroed(ZERO_LEN).into())
};
if bytes.is_empty() {
return None;
}
Some(bytes)
}
}
impl Journal<PartitionJournalMemStorage> for PartitionJournal<PartitionJournalMemStorage> {
type Header = PrepareHeader;
type Entry = JournalBuffer;
#[rustfmt::skip]
type HeaderRef<'a> = &'a Self::Header;
fn header(&self, idx: usize) -> Option<Self::HeaderRef<'_>> {
let headers = unsafe { &mut *self.headers.get() };
headers.get(idx)
}
fn previous_header(&self, header: &Self::Header) -> Option<Self::HeaderRef<'_>> {
if header.op == 0 {
return None;
}
let prev_op = header.op - 1;
let headers = unsafe { &*self.headers.get() };
headers.iter().find(|candidate| candidate.op == prev_op)
}
async fn append(&self, entry: Self::Entry) -> io::Result<()> {
self.append_with_meta(entry).await.map(|_| ())
}
async fn entry(&self, header: &Self::Header) -> Option<Self::Entry> {
self.bytes_by_op(header.op).await
}
}
pub fn select_batch_slice(
batch: &SendMessages2Ref<'_>,
query: MessageLookup,
already_matched: u32,
) -> Option<SelectedBatchSlice> {
let remaining = query.count().saturating_sub(already_matched);
let batch_message_count = batch.message_count();
if remaining == 0 || batch_message_count == 0 {
return None;
}
let mut start = None;
let mut end = 0usize;
let mut matched = 0u32;
let mut last_matching_offset = None;
let ceiling = query.ceiling();
for record in batch.iter_with_offsets() {
let offset = batch.header.base_offset + u64::from(record.message.header.offset_delta);
// Offsets within a batch ascend with the record index, so once we pass
// the commit frontier every later record is uncommitted too: stop here
// rather than skipping, which would punch a hole into the byte slice.
if offset > ceiling {
break;
}
let selected = match query {
MessageLookup::Offset {
offset: query_offset,
..
} => offset >= query_offset,
MessageLookup::Timestamp { timestamp, .. } => {
batch.header.origin_timestamp + u64::from(record.message.header.timestamp_delta)
>= timestamp
}
};
if !selected {
continue;
}
start.get_or_insert(record.start);
end = record.end;
matched += 1;
last_matching_offset = Some(offset);
if matched == remaining {
break;
}
}
Some(SelectedBatchSlice {
start: start?,
end,
matched_messages: matched,
last_matching_offset: last_matching_offset?,
})
}
/// Push the fragments for one selected batch, shared by the resident-journal
/// walk and the disk-chunk walk. `source` holds a stamped
/// `[256B SendMessages2Header][blob]` batch starting at byte `batch_base`
/// (the disk walk passes the chunk cursor; the resident walk passes
/// `size_of::<PrepareHeader>()`, the batch's offset past the prepare header).
/// A full-body selection forwards the original batch bytes by reference; a
/// partial selection emits a rewritten header (clamped length/count/checksum)
/// plus a body slice.
pub fn push_selected_batch_fragments(
fragments: &mut PollFragments<4096>,
last_matching_offset: &mut Option<u64>,
matched_messages: &mut u32,
source: &Frozen<4096>,
batch_base: usize,
batch: &SendMessages2Ref<'_>,
selection: SelectedBatchSlice,
) {
let full_body_selected = selection.start == 0 && selection.end == batch.blob().len();
if full_body_selected {
fragments.push(Fragment::slice(
source.clone(),
batch_base,
batch_base + batch.header.total_size(),
));
} else {
let mut rewritten = batch.header;
rewritten.batch_length =
u64::try_from(COMMAND_HEADER_SIZE + (selection.end - selection.start))
.expect("sliced batch length exceeds u64::MAX");
rewritten.message_count = selection.matched_messages;
rewritten.batch_checksum = rewritten.checksum_for_blob(
batch
.blob()
.get(selection.start..selection.end)
.expect("selected batch slice must stay within blob bounds"),
);
fragments.push(Fragment::whole(rewritten.into_frozen()));
fragments.push(Fragment::slice(
source.clone(),
batch_base + COMMAND_HEADER_SIZE + selection.start,
batch_base + COMMAND_HEADER_SIZE + selection.end,
));
}
*last_matching_offset = Some(selection.last_matching_offset);
*matched_messages += selection.matched_messages;
}
/// Decode one resident `Frozen` entry and push its matching fragments. Shared by
/// the live storage walk and the owned-snapshot walk so the corrupt-header skip
/// and `SendMessages` filter live in one place. Skips (never panics) on a short
/// or undecodable entry: a poll must not crash the shard on bad storage.
fn try_push_resident_entry(
prepare: &Frozen<4096>,
query: MessageLookup,
fragments: &mut PollFragments<4096>,
last_matching_offset: &mut Option<u64>,
matched_messages: &mut u32,
) {
let Some(header_bytes) = prepare.as_slice().get(..PREPARE_HEADER_SIZE) else {
return;
};
let Ok(header) = bytemuck::checked::try_from_bytes::<PrepareHeader>(header_bytes) else {
warn!(
target: "iggy.partitions.diag",
"partition journal poll: skipping entry with undecodable prepare header"
);
return;
};
if header.operation != Operation::SendMessages {
return;
}
let Ok(batch) = decode_prepare_slice(prepare.as_slice()) else {
return;
};
let Some(selection) = select_batch_slice(&batch, query, *matched_messages) else {
return;
};
// The batch's 256B header sits right after the prepare header in a resident
// entry (see `decode_prepare_slice`), so the batch base is `PREPARE_HEADER_SIZE`.
push_selected_batch_fragments(
fragments,
last_matching_offset,
matched_messages,
prepare,
PREPARE_HEADER_SIZE,
&batch,
selection,
);
}
/// Poll an owned, point-in-time snapshot of the resident journal tail.
/// `entries` are op-ascending `Frozen` clones captured while the partition
/// borrow was held; this runs off the borrow on owned data, so no concurrent
/// commit/eviction can interleave. Mirrors [`PartitionJournal::get_sync`] but
/// over owned entries: a single forward walk where `select_batch_slice` filters
/// by `query`, which is equivalent to the live `candidate_start_op` seek (a
/// batch entirely before the query bound contributes no records).
///
/// Used both for retention-recovery (disk walked clean, serve the journal with
/// the original query) and, after a contiguity check by the caller, for the
/// disk-tier straddle continuation. Returns `None` when nothing matched.
//
// Plain `pub` (not `pub(crate)`): the `journal` module is private, so this is
// not externally reachable, and `pub(crate)` here trips `redundant_pub_crate`.
// Matches `select_batch_slice` above.
pub fn select_resident(
entries: &[Frozen<4096>],
query: MessageLookup,
) -> Option<PollQueryResult<4096>> {
let count = query.count();
if count == 0 {
return None;
}
let mut fragments = PollFragments::new();
let mut last_matching_offset = None;
let mut matched_messages = 0u32;
for prepare in entries {
if matched_messages >= count {
break;
}
try_push_resident_entry(
prepare,
query,
&mut fragments,
&mut last_matching_offset,
&mut matched_messages,
);
}
(!fragments.is_empty()).then_some((fragments, last_matching_offset))
}
#[cfg(test)]
mod tests {
use super::*;
use iggy_binary_protocol::{Command2, HEADER_SIZE};
use journal::Journal;
use server_common::Message;
fn build_prepare(op: u64, size: usize) -> Message<PrepareHeader> {
Message::<PrepareHeader>::new(size).transmute_header(|_, h: &mut PrepareHeader| {
h.command = Command2::Prepare;
h.op = op;
h.size = u32::try_from(size).expect("size fits in u32");
})
}
#[compio::test]
async fn entry_round_trips_bytes_for_retransmit() {
let journal = PartitionJournal::<PartitionJournalMemStorage>::default();
let payload_size = HEADER_SIZE + 64;
let prepare = build_prepare(3, payload_size);
let expected_bytes = prepare.as_slice().to_vec();
let frozen = prepare.into_frozen();
journal.append(frozen).await.expect("append");
let header = journal.header_by_op(3).expect("header for op 3");
let entry = journal
.entry(&header)
.await
.expect("entry for op 3 must exist");
assert_eq!(
entry.as_slice(),
expected_bytes.as_slice(),
"retransmit path must read back the exact bytes that were appended; \
cloning the returned Frozen is the sole payload copy"
);
let cloned = entry.clone();
assert_eq!(
cloned.as_slice(),
entry.as_slice(),
"cloning a journal entry must yield identical bytes (refcount bump, not deep copy)"
);
}
#[compio::test]
async fn committed_prefix_reads_then_evict_retains_uncommitted_tail() {
// A backup journals ops ahead of the commit frontier. Reading the
// committed prefix (op <= commit_max) must return only those without
// evicting; evicting it must keep the uncommitted tail resident +
// readable, with its headers intact, so a later commit of that tail
// still finds it (no commit_min wedge).
let journal = PartitionJournal::<PartitionJournalMemStorage>::default();
for op in 1..=4 {
journal
.append(build_prepare(op, HEADER_SIZE + 16).into_frozen())
.await
.expect("append");
}
let committed = journal.committed_prefix(2);
assert_eq!(
committed.len(),
2,
"ops 1 and 2 are the committed prefix and must be returned"
);
// Reading does not evict: the prefix stays resident until persisted.
assert!(
journal.header_by_op(1).is_some(),
"read must not evict op 1"
);
let retained = journal.evict_prefix(committed.len()).await;
assert_eq!(
retained.len(),
2,
"ops 3 and 4 stay resident after eviction"
);
// Committed ops are evicted from the index; uncommitted ops remain.
assert!(journal.header_by_op(1).is_none(), "op 1 must be evicted");
assert!(journal.header_by_op(2).is_none(), "op 2 must be evicted");
let header3 = journal.header_by_op(3).expect("op 3 must be retained");
let header4 = journal.header_by_op(4).expect("op 4 must be retained");
// Retained entries are still byte-readable after the storage rebuild.
for header in [header3, header4] {
let entry = journal
.entry(&header)
.await
.expect("retained entry must read back");
let stored = bytemuck::checked::try_from_bytes::<PrepareHeader>(
&entry[..std::mem::size_of::<PrepareHeader>()],
)
.expect("retained entry must hold a valid prepare header");
assert_eq!(stored.op, header.op);
}
// Advancing the frontier flushes the rest with no gap.
let committed = journal.committed_prefix(4);
let rest = journal.evict_prefix(committed.len()).await;
assert!(rest.is_empty(), "ops 3 and 4 flush on the next evict");
assert!(journal.is_empty(), "journal is empty once all ops flushed");
}
#[compio::test]
async fn committed_prefix_stops_at_gap() {
// Ops {1,2,4} resident, commit_max = 4. The contiguous committed prefix
// is {1,2}; op 4 must stay retained because op 3 is missing - flushing
// it would put op-4 bytes on the segment ahead of the op-3 hole and
// skew the durable offset past a gap advance_commit_min cannot cross.
let journal = PartitionJournal::<PartitionJournalMemStorage>::default();
for op in [1u64, 2, 4] {
journal
.append(build_prepare(op, HEADER_SIZE + 16).into_frozen())
.await
.expect("append");
}
let committed = journal.committed_prefix(4);
let ops: Vec<u64> = committed
.iter()
.map(|entry| {
bytemuck::checked::try_from_bytes::<PrepareHeader>(
&entry[..std::mem::size_of::<PrepareHeader>()],
)
.expect("entry holds a valid prepare header")
.op
})
.collect();
assert_eq!(ops, vec![1, 2], "prefix stops before the op 3 gap");
let retained = journal.evict_prefix(committed.len()).await;
assert_eq!(retained.len(), 1, "op 4 stays retained past the gap");
assert!(journal.header_by_op(4).is_some(), "op 4 still resident");
}
#[compio::test]
async fn committed_headers_from_stops_at_gap() {
let journal = PartitionJournal::<PartitionJournalMemStorage>::default();
for op in [1u64, 2, 4] {
journal
.append(build_prepare(op, HEADER_SIZE + 16).into_frozen())
.await
.expect("append");
}
// Contiguous run from op 1 stops before the missing op 3 even though
// op 4 is resident and within commit_max.
let run = journal.committed_headers_from(1, 4);
let ops: Vec<u64> = run.iter().map(|header| header.op).collect();
assert_eq!(
ops,
vec![1, 2],
"must stop at the op 3 gap, not skip to op 4"
);
assert!(
journal.committed_headers_from(5, 4).is_empty(),
"from_op past commit_max yields nothing"
);
}
}