blob: 8c3ca7131cdcbee6e574c94aa0751e8e9e2534df [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.
//! Owned poll reads without authority to change consumer progress.
//!
//! The pump snapshots file handles and resident fragments before a read can
//! yield. Execution returns facts about that snapshot. Only the partition
//! owner validates its history and accepts progress.
use crate::iggy_index::{IGGY_INDEX_SIZE, IggyIndexCache};
use crate::iggy_index_reader::IggyIndexReader;
use crate::journal::{
MessageLookup, push_selected_batch_fragments, select_batch_slice, unpin_sparse_source,
};
use crate::{PollFragments, PollingConsumer};
use compio::io::AsyncReadAtExt;
use iggy_binary_protocol::{WireError, batch};
use iggy_common::{ConsumerKind, IggyError};
use server_common::iobuf::{Frozen, Owned};
use server_common::poll::PollHistoryId;
use server_common::send_messages::{BatchIntegrity, COMMAND_HEADER_SIZE};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use tracing::{error, warn};
/// Byte cap for materializing a sealed segment's sparse index into its shared
/// read-state handle. Index density is one entry per flush, never per message:
/// at the default cadence a 1 GiB segment yields ~24 KiB of index, but
/// `messages_required_to_save = 1` flushes once per produced batch and the same
/// segment yields tens of MB (hundreds only when producers send one message per
/// batch), which a single poll must never read (or pin resident) in one go.
/// At or under the cap the whole file loads once and is
/// cached; above it every poll binary-searches the file with single-entry
/// preads instead, so resident index bytes per partition stay bounded by the
/// sealed-LRU capacity times this cap.
pub const SEALED_INDEX_RESIDENT_MAX_BYTES: u64 = 512 * 1024;
/// Where the disk tier's segment files live, resolved at plan-build time.
/// The two dir-less cases must stay distinct: only a genuinely file-less
/// partition may fall forward to the journal tier, while file-backed data
/// behind an unresolvable dir must fail closed (see [`DiskReadOutcome`]).
pub enum PartitionDirResolution {
/// The canonical partition directory to open segment files from.
Resolved(String),
/// No file-backed storage exists (simulated in-memory persistence):
/// there are no files to read and the journal tier is the only tier.
NoFiles,
/// File-backed storage exists but no directory was resolvable right now
/// (a live partition mid-rotation whose sealed segments dropped their
/// writer). Disk-resident data may be temporarily hidden, so the read
/// fails closed instead of letting the journal-forward skip it.
Unresolvable,
}
/// Per-sealed-segment read state, shared as a cheap `Rc` handle between the
/// owning partition and the off-borrow [`DiskReadPlan`] (a plain `Rc`, never a
/// partition reference, so the read runs off the pump). Both slots fill lazily
/// on the first sealed poll and are reused after. On retention retirement the
/// pump just drops its handle: the state frees once any in-flight poll holding
/// a clone finishes, and a cached fd meanwhile reads the unlinked inode, which
/// is consistent because retired paths are never recreated. A purge instead
/// wipes the slots in place (`SegmentedLog::invalidate_sealed_read_state`):
/// it recreates the same paths, so a clone surviving in a suspended walk must
/// re-open by path and observe the fresh files rather than serve purged data.
/// The active segment uses the [`Self::fd`] slot only: it grows under the
/// reader, so a size-derived memo on it would go stale, and its slot sits
/// outside the sealed LRU (`SegmentedLog::reset_read_state` drops it wherever
/// a segment changes sealed-ness).
#[derive(Debug, Default)]
pub struct SealedSegmentReadState {
/// Read-only descriptor; compio `File` clones share the kernel fd, so a hit
/// avoids the per-poll `openat` (an `io_uring` op prone to io-wq punts) and
/// preserves kernel readahead. `None` until the first poll opens it.
pub(crate) fd: RefCell<Option<compio::fs::File>>,
/// Sparse offset/timestamp index reloaded from the `.index` file the
/// segment dropped at rotation, so a poll resolves the start byte in
/// O(log n) instead of scanning the whole segment from byte 0 (the stall).
/// `None` until the first sealed poll loads it.
pub(crate) index: RefCell<Option<IggyIndexCache>>,
/// Whether the owning partition's sealed LRU currently tracks this handle.
/// Gates the fd store-back in `resolve_segment_file` for a SEALED segment
/// (the active segment's slot is outside the LRU, so it always fills): a
/// walk crosses every
/// sealed segment from the poll's start onward, but only the start segment
/// is LRU-touched, so an untracked fill would retain a descriptor the
/// `SEALED_READ_STATE_CAP` budget never counts. Set on touch, cleared on
/// evict; plain `Cell`, all access is same-thread (`Rc` handle).
pub(crate) tracked: Cell<bool>,
/// One-slot memo of the last file-backed offset resolution, so a
/// sequentially advancing consumer re-polling the same segment resolves
/// inside `[offset, valid_until)` with zero index-file reads. Only the
/// too-large-to-materialize index path consults it (a resident
/// [`Self::index`] already resolves in memory). Sealed segments are
/// immutable, so the memo cannot go stale; the one exception is a purge
/// recreating the same paths, which wipes this slot with the others.
/// Timestamp polls bypass it.
pub(crate) offset_cursor: Cell<Option<SealedOffsetCursor>>,
}
/// See [`SealedSegmentReadState::offset_cursor`].
#[derive(Debug, Clone, Copy)]
pub struct SealedOffsetCursor {
/// Offset of the resolved index entry (interval floor, inclusive).
pub(crate) offset: u64,
/// The successor index entry's offset (interval ceiling, exclusive);
/// `u64::MAX` when the resolved entry is the segment's last.
pub(crate) valid_until: u64,
/// Start byte the whole interval resolves to.
pub(crate) position: u64,
}
pub type SealedSegmentHandle = Rc<SealedSegmentReadState>;
/// Owned, borrow-free inputs for the disk tier of a poll (see module docs). A
/// sealed segment reuses its cached [`SealedSegmentReadState`] (read fd + sparse
/// index); the active segment reuses the cached fd but resolves from its
/// resident index, because sealed segments drop that index at rotation.
pub struct DiskReadPlan {
pub(crate) partition_dir: PartitionDirResolution,
/// Segments to walk, snapshotted from the poll's starting segment onward
/// (see `build_poll_plan`); `start_position` is the byte offset into the
/// first one.
pub(crate) segments: Vec<DiskSegment>,
pub(crate) start_position: u64,
pub(crate) start_index_offset: Option<u64>,
pub(crate) namespace_raw: u64,
/// Whether to verify each batch's `batch_checksum` against the bytes read.
/// Detection only; a mismatch fails the poll closed and repairs nothing.
pub(crate) validate_checksum: bool,
/// Mean encoded bytes per message on this partition, or `None` before it
/// has committed anything. Sizes the disk walk's reads, and is never a
/// bound on what a read may return, since messages vary in size within a
/// partition.
pub(crate) bytes_per_message: Option<u32>,
/// Widest batch this partition has committed, which is the smallest read
/// that is guaranteed to contain a whole one. The walk cannot advance on a
/// chunk holding no complete batch, so a count-derived estimate below this
/// buys nothing and costs the re-read it triggers.
pub(crate) widest_batch_bytes: u64,
}
pub struct DiskSegment {
pub(crate) start_offset: u64,
pub(crate) persisted: u64,
/// Shared read state, cloned from the owning partition at plan time. See
/// [`SealedSegmentReadState`].
pub(crate) read_state: SealedSegmentHandle,
/// Whether the segment was sealed when the plan was built. Only a sealed
/// segment resolves its start byte from the shared sparse index; the active
/// one grows under the reader and uses its resident index instead.
pub(crate) sealed: bool,
}
/// Admission inputs carried by a read without access to consumer progress maps.
#[derive(Debug)]
pub struct PollContext {
/// History captured at planning, which must still match at completion.
pub(crate) history: PollHistoryId,
/// Consumer or group whose progress the owner may update after admission.
pub(crate) consumer: PollingConsumer,
/// For nonempty results, whether to advance the stored offset locally.
/// Group `last_polled` progress also advances when this is false.
pub(crate) auto_commit: bool,
}
/// An owned read result awaiting validation by the partition owner.
/// Finishing I/O does not authorize a successful reply or a progress update.
#[derive(Debug)]
pub struct PollReadResult {
pub(crate) context: PollContext,
/// Selected message bytes, which remain unaccepted until owner validation.
pub(crate) fragments: PollFragments,
/// Partition message frontier captured at planning, not consumer progress.
pub(crate) commit_offset: u64,
/// Inclusive offset of the last selected message, or `None` for no match.
pub(crate) last_matching_offset: Option<u64>,
}
impl PollReadResult {
#[must_use]
pub const fn consumer_kind(&self) -> ConsumerKind {
match self.context.consumer {
PollingConsumer::Consumer(..) => ConsumerKind::Consumer,
PollingConsumer::ConsumerGroup(..) => ConsumerKind::ConsumerGroup,
}
}
}
/// Owned, point-in-time snapshot of the resident journal tail for the disk-tier
/// straddle. `entries` are op-ascending `Frozen` clones (refcount bumps).
pub struct ResidentTailSnapshot {
pub(crate) oldest_resident: Option<u64>,
pub(crate) entries: Vec<Frozen<4096>>,
}
impl ResidentTailSnapshot {
/// Offset query to continue a disk match into the resident tail, or `None`
/// when the tail cannot contiguously extend it. The snapshot is
/// point-in-time, so the gate (`oldest_resident <= last + 1`) is race-free:
/// a commit after the snapshot cannot have evicted the run. Without it,
/// splicing the next resident op over an evicted run silently skips offsets.
fn straddle_continuation(
&self,
last_offset: u64,
remaining: u32,
ceiling: u64,
) -> Option<MessageLookup> {
(remaining > 0
&& self
.oldest_resident
.is_some_and(|oldest| oldest <= last_offset + 1))
.then_some(MessageLookup::Offset {
offset: last_offset + 1,
count: remaining,
ceiling,
})
}
}
/// Owned read snapshot that may outlive the partition history it captured.
///
/// Execution yields a [`PollReadResult`] that the owner must accept through
/// [`crate::IggyPartitions::complete_poll`] before replying or updating progress.
pub struct PollPlan {
/// Monotone high-water snapshot taken before the disk read, so it may lag a
/// concurrent producer by the poll duration and self-corrects next poll.
pub(crate) commit_offset: u64,
pub(crate) context: PollContext,
pub(crate) tier: PollTier,
}
impl PollPlan {
/// Whether this snapshot needs disk I/O on a detached read task.
/// Resident reads execute and complete synchronously on the owner.
#[must_use]
pub const fn needs_off_pump_io(&self) -> bool {
matches!(self.tier, PollTier::Disk { .. })
}
/// Read the captured snapshot without changing consumer progress.
/// The result still requires owner validation, including when it is empty.
pub async fn execute(self) -> PollReadResult {
let commit_offset = self.commit_offset;
let (fragments, last_matching_offset) = match self.tier {
PollTier::Empty => (PollFragments::new(), None),
PollTier::Resident {
fragments,
last_matching_offset,
} => (fragments, last_matching_offset),
PollTier::Disk {
disk,
query,
resident_tail,
} => match disk.read_disk(query).await {
// Disk walked cleanly and matched nothing: the query offset is
// below disk retention too, so the match (if any) is journal-
// resident. Serve the journal forward (retention-recovery) from
// the resident-tail snapshot with the ORIGINAL query (offset or
// timestamp); no contiguity gate, this is not a straddle.
DiskReadOutcome::Empty => {
crate::journal::select_resident(&resident_tail.entries, query)
.unwrap_or_else(|| (PollFragments::new(), None))
}
// Disk read stopped on a fault. Fail-closed: return an empty poll
// WITHOUT the journal-forward fallback. Falling forward here would
// splice the next resident op over the unreadable run and silently
// skip live data.
//
// TODO(partitions): the poll reply has no error channel, so this
// reaches the consumer as an ordinary empty poll. Fair for a transient
// IO fault, wrong for a batch that failed its own checksum: data
// damaged at rest never reads again, so the consumer waits forever.
// Surfacing it needs a status on the poll reply, an SDK-visible change
// on every client. Until then the ERROR in `walk_disk_chunk` is the
// only signal, and it is server-side only.
DiskReadOutcome::Faulted => (PollFragments::new(), None),
// Straddle: continue past the last disk match into the resident
// tail (gate + race argument live on `straddle_continuation`).
DiskReadOutcome::Matched {
mut fragments,
last_matching_offset,
matched,
} => {
let remaining = query.count().saturating_sub(matched);
let continuation = last_matching_offset
.and_then(|last_offset| {
resident_tail.straddle_continuation(
last_offset,
remaining,
query.ceiling(),
)
})
.and_then(|query| {
crate::journal::select_resident(&resident_tail.entries, query)
});
match continuation {
Some((journal_fragments, journal_last)) => {
fragments.extend(journal_fragments);
(fragments, journal_last.or(last_matching_offset))
}
None => (fragments, last_matching_offset),
}
}
},
};
PollReadResult {
context: self.context,
commit_offset,
fragments,
last_matching_offset,
}
}
/// Read a resident snapshot synchronously on the pump.
/// The returned result requires the same owner validation as a disk read.
///
/// # Panics
/// Panics if [`Self::needs_off_pump_io`] is true.
#[must_use]
pub fn execute_resident(self) -> PollReadResult {
let commit_offset = self.commit_offset;
let (fragments, last_matching_offset) = match self.tier {
PollTier::Empty => (PollFragments::new(), None),
PollTier::Resident {
fragments,
last_matching_offset,
} => (fragments, last_matching_offset),
// `needs_off_pump_io` is true for every Disk tier, so the dispatch
// gate never routes one here.
PollTier::Disk { .. } => {
unreachable!("execute_resident on Disk tier; needs_off_pump_io guards this")
}
};
PollReadResult {
context: self.context,
commit_offset,
fragments,
last_matching_offset,
}
}
}
pub enum PollTier {
Empty,
Resident {
fragments: PollFragments<4096>,
last_matching_offset: Option<u64>,
},
Disk {
disk: DiskReadPlan,
query: MessageLookup,
/// Resident journal tail snapshot for the straddle continuation,
/// captured at plan time so the splice runs off the partition borrow.
resident_tail: ResidentTailSnapshot,
},
}
/// Outcome of [`DiskReadPlan::read_disk`], distinguishing a benign empty walk
/// from an IO fault so the caller can fail-closed.
///
/// A faulted segment may hold data that is present-but-unreadable right now;
/// the disk walk stops at the fault (never advancing to later segments) so a
/// poll cannot return a gap. The caller must NOT fall the journal forward over
/// a `Faulted` result, or it would splice the next resident op over the
/// unreadable run and silently skip live messages.
pub enum DiskReadOutcome {
/// Walk produced matches (possibly a partial prefix if a fault stopped it).
Matched {
fragments: PollFragments<4096>,
last_matching_offset: Option<u64>,
matched: u32,
},
/// Walk completed with no fault and matched nothing. The query offset is
/// below disk retention too, so the caller may serve the journal forward
/// (retention-recovery) without skipping anything.
Empty,
/// Walk stopped on an IO fault before matching anything. Fail-closed: the
/// caller returns an empty poll so the consumer cursor does not advance
/// past data that may still be present-but-unreadable.
Faulted,
}
/// Ceiling for ordinary disk reads. An incomplete batch may require one
/// larger re-read, without widening subsequent chunks or segments.
const DISK_POLL_CHUNK_MAX: u64 = 1 << 20;
/// Smallest first read of a disk poll. Below this the syscall and the segment
/// walk cost more than the bytes the smaller read saves, and a poll for a
/// handful of messages would issue a read per batch.
const DISK_POLL_CHUNK_MIN: u64 = 64 << 10;
/// How the chunk loop over one segment ended.
enum SegmentWalk {
/// The segment is exhausted or the requested count is filled. The walk
/// may continue into the next segment.
Done,
/// Fail-closed: the segment may hold present-but-unreadable or corrupt
/// data, so no later segment may be served over it.
Faulted,
}
/// The state one disk walk carries across its segments.
struct DiskWalk {
/// Byte offset into the segment being walked; reset at each boundary.
position: u64,
/// Messages between the resolved index entry and the requested offset,
/// which the first read has to cover on top of what the poll asked for.
/// Cleared once anything matches, since the walk is then at the target.
skipped: u32,
matched: u32,
fragments: PollFragments<4096>,
last_matching_offset: Option<u64>,
/// Batch width learned from an incomplete read, capped at the chunk ceiling.
batch_read_floor: u64,
#[cfg(feature = "poll-diagnostics")]
requested_bytes: u64,
#[cfg(feature = "poll-diagnostics")]
chunk_reads: u32,
}
impl DiskWalk {
/// Messages the next read has to cover: what the poll still wants, plus
/// the run the sparse index left in front of the first match.
const fn remaining_to_read(&self, count: u32) -> u32 {
(count - self.matched).saturating_add(self.skipped)
}
fn starting_at(position: u64, skipped: u32) -> Self {
Self {
position,
skipped,
matched: 0,
fragments: PollFragments::new(),
last_matching_offset: None,
batch_read_floor: 0,
#[cfg(feature = "poll-diagnostics")]
requested_bytes: 0,
#[cfg(feature = "poll-diagnostics")]
chunk_reads: 0,
}
}
}
impl DiskReadPlan {
/// Bytes to read for the next `remaining` messages.
///
/// A poll asks for a message count and the walk reads bytes, so the two are
/// bridged by the partition's own mean encoded size. Reading a fixed
/// megabyte instead costs a poll for a thousand hundred-byte messages ten
/// times the bytes it returns, and the sparse-selection copy that follows
/// scales with the chunk rather than with the selection.
///
/// The count-derived estimate alone is not enough, because a batch is the
/// unit the walk can consume. A poll for fewer messages than a producer put
/// in one batch estimates below that batch, decodes nothing, and pays the
/// quadrupling re-read below: at one message short of a full batch that is
/// five times the bytes a flat megabyte read would have taken. So the
/// estimate is floored at the widest batch this partition has committed,
/// which is the smallest read guaranteed to hold a whole one.
///
/// The result is still not a bound. Messages vary in size, the starting
/// offset can sit inside a batch the index resolved before it, and a batch
/// wider than the ceiling still grows through the re-read path. The ceiling
/// is what every poll read before it was sized at all, so no poll reads
/// more than it used to.
fn chunk_len(&self, remaining: u32) -> u64 {
let Some(bytes_per_message) = self.bytes_per_message else {
return DISK_POLL_CHUNK_MAX;
};
u64::from(bytes_per_message)
.saturating_mul(u64::from(remaining))
.saturating_add(COMMAND_HEADER_SIZE as u64)
.max(self.widest_batch_bytes)
.clamp(DISK_POLL_CHUNK_MIN, DISK_POLL_CHUNK_MAX)
}
/// Serve a poll from the on-disk segment files, off the partition borrow.
/// Reads from owned descriptors so no partition reference is held across
/// the file IO. Walks stamped `[256B BatchHeader][blob]` batches in
/// chunked reads, re-reading a batch split across a chunk boundary in the
/// next chunk.
pub(crate) async fn read_disk(self, query: MessageLookup) -> DiskReadOutcome {
let count = query.count();
if count == 0 || self.segments.is_empty() {
return DiskReadOutcome::Empty;
}
let partition_dir = match &self.partition_dir {
PartitionDirResolution::Resolved(dir) => dir.as_str(),
// Simulated in-memory persistence: no files exist, so this is not
// an IO fault on present data. `Empty` lets the caller serve the
// resident journal tier (the sim's only tier) without skipping
// anything.
PartitionDirResolution::NoFiles => return DiskReadOutcome::Empty,
// File-backed data exists but the dir was unresolvable at plan
// time (mid-rotation). Fail-closed like an IO fault: the
// journal-forward would splice resident ops over the hidden
// disk-resident offsets. A later poll resolves the dir again.
PartitionDirResolution::Unresolvable => {
warn!(
target: "iggy.partitions.diag",
plane = "partitions",
namespace_raw = self.namespace_raw,
segment_count = self.segments.len(),
"disk poll: file-backed partition has no resolvable dir; failing closed"
);
return DiskReadOutcome::Faulted;
}
};
// `start_position` applies to the first snapshotted segment; each later
// segment is walked from byte 0 (reset at the end of every iteration).
//
// A sealed first segment dropped its resident index at rotation, so
// `disk_poll_start` fell back to byte 0. Reload the sparse index (once,
// then cached) and resolve the start byte so the walk skips straight to
// the target instead of scanning the whole segment - the poll stall. A
// miss or load failure keeps `start_position` (the pre-existing
// full-scan fallback). An active first segment keeps its
// resident-index-resolved `start_position` untouched.
let resolved = match self.segments.first() {
Some(first) => self.resolve_sealed_start(first, query, partition_dir).await,
None => None,
};
let position = resolved.map_or(self.start_position, |(position, _)| position);
// The index is sparse, so the entry it resolved can sit a whole flush
// group before the requested offset. The walk has to read that run to
// reach the first match, and sizing the read from the requested count
// alone would cross it in floor-sized reads.
let entry_offset = resolved
.map(|(_, offset)| offset)
.or(self.start_index_offset);
let skipped = match (entry_offset, query) {
(Some(entry_offset), MessageLookup::Offset { offset, .. }) => {
u32::try_from(offset.saturating_sub(entry_offset)).unwrap_or(u32::MAX)
}
_ => 0,
};
let mut walk = DiskWalk::starting_at(position, skipped);
// Set when an open/read retry exhausts. The walk breaks immediately so
// later segments are never read into the result (which would leave a
// gap at the faulted segment). Pre-fault matches are still served.
let mut faulted = false;
for segment in &self.segments {
if walk.matched >= count {
break;
}
let persisted = segment.persisted;
if persisted == 0 || walk.position >= persisted {
// Benign skip: nothing persisted for this segment yet, or the
// start position is already past it. Not a fault.
walk.position = 0;
continue;
}
let path = format!("{partition_dir}/{:0>20}.log", segment.start_offset);
let Some(file) = self.resolve_segment_file(segment, &path).await else {
// Open exhausted retries: the segment may hold present-but-
// unreadable data. Stop here rather than walking past it.
faulted = true;
break;
};
if matches!(
self.walk_segment(&file, query, count, persisted, &mut walk)
.await,
SegmentWalk::Faulted
) {
faulted = true;
break;
}
walk.position = 0;
}
// The three ratios a read-sizing change is judged on: bytes asked of
// the file API, bytes actually served, and the reads it took to get
// them. Per poll, so a short run answers whether a sized first read
// pays for itself before anything becomes a permanent counter.
#[cfg(feature = "poll-diagnostics")]
tracing::debug!(
target: "iggy.partitions.poll_diagnostics",
namespace_raw = self.namespace_raw,
requested_bytes = walk.requested_bytes,
served_bytes = walk.fragments.iter().map(|fragment| fragment.len() as u64).sum::<u64>(),
chunk_reads = walk.chunk_reads,
requested_count = count,
matched = walk.matched,
"disk poll read accounting"
);
if walk.matched > 0 {
// Pre-fault matches are always a contiguous prefix (the walk stops
// at the first fault), so a partial result carries no gap.
DiskReadOutcome::Matched {
fragments: walk.fragments,
last_matching_offset: walk.last_matching_offset,
matched: walk.matched,
}
} else if faulted {
DiskReadOutcome::Faulted
} else {
DiskReadOutcome::Empty
}
}
/// Read one segment from `walk.position` until the count is filled, the
/// segment is exhausted, or the walk must fail closed.
///
/// The read length is the chunk clipped to the segment's persisted bytes,
/// so narrowing it to a `usize` cannot truncate. The chunk itself is not
/// bounded by `DISK_POLL_CHUNK_MAX`: a batch wider than the ceiling grows
/// past it below.
#[allow(clippy::cast_possible_truncation)]
async fn walk_segment(
&self,
file: &compio::fs::File,
query: MessageLookup,
count: u32,
persisted: u64,
walk: &mut DiskWalk,
) -> SegmentWalk {
let mut chunk_len = self
.chunk_len(walk.remaining_to_read(count))
.max(walk.batch_read_floor);
while walk.matched < count && walk.position < persisted {
let len = (persisted - walk.position).min(chunk_len) as usize;
let Some(chunk) = self.read_chunk_with_retry(file, len, walk).await else {
// Chunk read exhausted retries: same fail-closed reason as
// a failed open.
return SegmentWalk::Faulted;
};
let fragments_before_chunk = walk.fragments.len();
let ChunkWalk {
consumed,
needed,
corrupt,
} = walk_disk_chunk(
&chunk,
query,
count,
&mut walk.matched,
&mut walk.fragments,
&mut walk.last_matching_offset,
if self.validate_checksum {
BatchIntegrity::Verify
} else {
BatchIntegrity::LayoutOnly
},
self.namespace_raw,
);
// Detached from the pump, so the ratio alone bounds the copy.
unpin_sparse_source(
&mut walk.fragments,
fragments_before_chunk,
&chunk,
usize::MAX,
);
if corrupt {
// A batch that does not match its own checksum. Fail closed like
// an IO fault: serving it hands a consumer data provably not what
// was written, and skipping ahead punches a silent gap.
return SegmentWalk::Faulted;
}
if consumed == 0 {
if (len as u64) >= persisted - walk.position {
// The whole remainder fit yet no complete batch decoded: a
// corrupt batch in this segment. Fail-closed like an IO
// fault so a later segment is never served over the corrupt
// run, which would punch a silent gap into the poll.
return SegmentWalk::Faulted;
}
// A single batch larger than the chunk. Its own header says
// how wide it is, so re-read exactly that; only a header this
// read could not reach leaves the old quadrupling.
if needed as u64 > persisted - walk.position {
return SegmentWalk::Faulted;
}
chunk_len = if needed > len {
walk.batch_read_floor = walk
.batch_read_floor
.max((needed as u64).min(DISK_POLL_CHUNK_MAX));
needed as u64
} else {
chunk_len.saturating_mul(4)
};
continue;
}
if walk.matched > 0 {
walk.skipped = 0;
}
chunk_len = self
.chunk_len(walk.remaining_to_read(count))
.max(walk.batch_read_floor);
walk.position += consumed as u64;
}
SegmentWalk::Done
}
/// Resolve the read-only descriptor for `segment`'s file. A hit clones the
/// cached fd (sharing the kernel fd, no syscall); a miss opens by path and
/// stores the fd back so later polls skip the `openat`. Returns `None` only
/// when the open exhausts its retries (the caller fails closed).
async fn resolve_segment_file(
&self,
segment: &DiskSegment,
path: &str,
) -> Option<compio::fs::File> {
let handle = &segment.read_state;
// Borrow only to clone the `Option<File>` out, never across the await.
if let Some(cached) = handle.fd.borrow().clone() {
return Some(cached);
}
let file = self.open_segment_with_retry(path).await?;
// A sealed segment stores back only while the pump tracks its handle;
// an untracked fill (walk-through segment, or a slot evicted mid-poll)
// would pin an fd outside the LRU budget, so it opens transiently
// instead. The active segment's slot is not LRU-budgeted (one per
// partition, dropped when it seals), so it always fills. Benign race: a
// concurrent poll of the same segment may have filled the slot while
// this open was in flight; overwriting with an equivalent fd (same
// inode) is harmless, as is filling a slot the pump orphaned mid-poll.
if !segment.sealed || handle.tracked.get() {
*handle.fd.borrow_mut() = Some(file.clone());
}
Some(file)
}
/// Resolve the start byte for the poll's target segment from its sparse
/// index. An index at or under [`SEALED_INDEX_RESIDENT_MAX_BYTES`] loads
/// whole on the first sealed poll and is cached on the shared handle; a
/// larger one is binary-searched on file every poll and never materialized
/// (see the constant). Returns `None` (keep the byte-0 fallback) for the
/// active segment, a below-range query, or an IO failure.
async fn resolve_sealed_start(
&self,
segment: &DiskSegment,
query: MessageLookup,
partition_dir: &str,
) -> Option<(u64, u64)> {
// The active segment grows under the reader, so neither the shared
// sparse index nor the offset memo can describe it; its own resident
// index already resolved `start_position`.
if !segment.sealed {
return None;
}
let handle = &segment.read_state;
// Cache hit: resolve under a short borrow, never across the await.
let cached = handle
.index
.borrow()
.as_ref()
.map(|index| resolve_index_position(index, query));
if let Some(resolved) = cached {
return resolved;
}
// No resident index, so this is the file-backed path a sequential
// consumer would otherwise binary-search on disk every poll: answer
// from the memoized interval when the query lands inside it.
if let (MessageLookup::Offset { offset, .. }, Some(cursor)) =
(query, handle.offset_cursor.get())
&& offset >= cursor.offset
&& offset < cursor.valid_until
{
return Some((cursor.position, cursor.offset));
}
let path = format!("{partition_dir}/{:0>20}.index", segment.start_offset);
let reader = match IggyIndexReader::new(&path).await {
Ok(reader) => reader,
Err(error) => {
self.warn_sparse_index_fallback(&path, "open", &error);
return None;
}
};
let entry_count = match reader.entry_count().await {
Ok(entry_count) => entry_count,
Err(error) => {
self.warn_sparse_index_fallback(&path, "entry_count", &error);
return None;
}
};
if entry_count.saturating_mul(IGGY_INDEX_SIZE as u64) <= SEALED_INDEX_RESIDENT_MAX_BYTES {
let index = match reader.load_all().await {
Ok(index) => index,
Err(error) => {
self.warn_sparse_index_fallback(&path, "load", &error);
return None;
}
};
let resolved = resolve_index_position(&index, query);
*handle.index.borrow_mut() = Some(index);
return resolved;
}
let looked_up = match query {
MessageLookup::Offset { offset, .. } => {
match reader
.offset_lower_bound_with_successor(entry_count, offset)
.await
{
Ok(resolved) => Ok(resolved.map(|(entry, successor_offset)| {
handle.offset_cursor.set(Some(SealedOffsetCursor {
offset: entry.offset,
valid_until: successor_offset.unwrap_or(u64::MAX),
position: entry.position,
}));
entry
})),
Err(error) => Err(error),
}
}
MessageLookup::Timestamp { timestamp, .. } => {
reader.timestamp_lower_bound(entry_count, timestamp).await
}
};
match looked_up {
Ok(entry) => entry.map(|entry| (entry.position, entry.offset)),
Err(error) => {
self.warn_sparse_index_fallback(&path, "lower_bound", &error);
None
}
}
}
/// The sparse index is unavailable or unreadable; the caller falls back to
/// a byte-0 scan (the pre-existing behavior) and retries on the next poll.
fn warn_sparse_index_fallback(&self, path: &str, stage: &str, error: &IggyError) {
warn!(
target: "iggy.partitions.diag",
plane = "partitions",
namespace_raw = self.namespace_raw,
path,
stage,
%error,
"disk poll: sparse index unavailable; scanning from segment start"
);
}
/// Open a segment file for a disk poll, retrying transient IO failures (fd
/// pressure under heavy parallel load) so one failed syscall does not
/// silently collapse the poll into an empty result.
async fn open_segment_with_retry(&self, path: &str) -> Option<compio::fs::File> {
for attempt in 0..3u8 {
match compio::fs::File::open(path).await {
Ok(file) => return Some(file),
Err(error) => {
warn!(
target: "iggy.partitions.diag",
plane = "partitions",
namespace_raw = self.namespace_raw,
path,
attempt,
%error,
"disk poll: failed to open segment file"
);
compio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
None
}
/// Read one chunk for a disk poll, retrying transient IO failures.
#[cfg_attr(
not(feature = "poll-diagnostics"),
allow(
clippy::needless_pass_by_ref_mut,
reason = "The mutable reference is needed to update counters when poll diagnostics are enabled"
)
)]
async fn read_chunk_with_retry(
&self,
file: &compio::fs::File,
len: usize,
walk: &mut DiskWalk,
) -> Option<Frozen<4096>> {
let position = walk.position;
for attempt in 0..3u8 {
#[cfg(feature = "poll-diagnostics")]
{
walk.requested_bytes += len as u64;
walk.chunk_reads += 1;
}
// `with_capacity` (len == 0, capacity == len) instead of `zeroed`:
// `read_exact_at` fills the whole capacity in place and advances the
// length via `SetLen`, so the `zeroed` memset of up to 1MiB per
// chunk was pure waste - every byte is overwritten by the read.
let buffer = Owned::<4096>::with_capacity(len);
let compio::BufResult(read, buffer) = file.read_exact_at(buffer, position).await;
match read {
Ok(()) => return Some(Frozen::from(buffer)),
Err(error) => {
warn!(
target: "iggy.partitions.diag",
plane = "partitions",
namespace_raw = self.namespace_raw,
position,
attempt,
%error,
"disk poll: segment read failed"
);
compio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
None
}
}
/// Byte position of the sparse-index entry at or below the query's offset /
/// timestamp, or `None` when the query is below the first indexed entry (the
/// caller then scans from the segment start). Mirrors `disk_poll_start`'s
/// resident-index resolution for the sealed, off-pump path.
/// Start byte for `query`, and the offset of the index entry it resolved to.
/// The entry sits at or before the requested offset, so the difference is the
/// run the walk has to skip before it can match anything.
fn resolve_index_position(index: &IggyIndexCache, query: MessageLookup) -> Option<(u64, u64)> {
match query {
MessageLookup::Offset { offset, .. } => index.offset_lower_bound(offset),
MessageLookup::Timestamp { timestamp, .. } => index.timestamp_lower_bound(timestamp),
}
.map(|entry| (entry.position, entry.offset))
}
/// Walk stamped `[256B BatchHeader][blob]` batches in one disk
/// chunk, pushing matching fragments. Returns bytes consumed: the start
/// of the first batch that did not fully fit in the chunk (the caller
/// re-reads from there), or the chunk end when everything decoded.
#[allow(clippy::too_many_arguments)]
fn walk_disk_chunk(
chunk: &Frozen<4096>,
query: MessageLookup,
count: u32,
matched: &mut u32,
fragments: &mut PollFragments<4096>,
last_matching_offset: &mut Option<u64>,
integrity: BatchIntegrity,
namespace_raw: u64,
) -> ChunkWalk {
let bytes: &[u8] = chunk;
let mut cursor = 0usize;
let mut needed = 0usize;
while *matched < count && cursor + COMMAND_HEADER_SIZE <= bytes.len() {
let batch = match batch::decode_batch_slice_with(&bytes[cursor..], integrity) {
Ok(batch) => batch,
Err(WireError::InvalidBatchChecksum {
stored: found,
computed: expected,
base_offset,
}) => {
// Distinguished from the incomplete-tail case below: this batch is
// entirely present and fails its own checksum, so it is damaged at rest.
error!(
target: "iggy.partitions.diag",
plane = "partitions",
namespace_raw,
base_offset,
expected,
found,
position = cursor,
"disk poll: batch checksum mismatch; segment is corrupt at rest"
);
return ChunkWalk {
consumed: cursor.min(bytes.len()),
needed: 0,
corrupt: true,
};
}
Err(WireError::UnexpectedEof { need, .. })
if need <= journal::partition_journal::PREPARE_BYTES_MAX =>
{
needed = need;
break;
}
Err(error) => {
error!(namespace_raw, position = cursor, %error, "invalid disk batch");
return ChunkWalk {
consumed: cursor,
needed: 0,
corrupt: true,
};
}
};
let total_size = batch.header.total_size();
if let Some(selection) = select_batch_slice(&batch, query, *matched) {
// On disk a batch is the bare `[256B header][blob]`, so the batch
// base is the chunk cursor (no preceding prepare header).
push_selected_batch_fragments(
fragments,
last_matching_offset,
matched,
chunk,
cursor,
&batch,
selection,
);
}
cursor += total_size;
}
ChunkWalk {
consumed: cursor.min(bytes.len()),
needed,
corrupt: false,
}
}
/// How far [`walk_disk_chunk`] got, and whether it stopped on corruption rather
/// than on a batch that simply did not fit in the chunk.
struct ChunkWalk {
consumed: usize,
/// Bytes the batch that did not fit needs in full, from its own header, or
/// zero when that header could not be read. Lets the caller re-read
/// exactly the batch instead of doubling its way up to it.
needed: usize,
corrupt: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::iggy_index::IggyIndex;
#[cfg(feature = "poll-diagnostics")]
use bytes::Bytes;
use compio::io::AsyncWriteAtExt;
use server_common::iobuf::Owned;
#[cfg(feature = "poll-diagnostics")]
use server_common::send_messages::{
IggyMessage, IggyMessageHeader, IggyMessages, SendMessagesOwned,
};
#[cfg(feature = "poll-diagnostics")]
use server_common::sharding::IggyNamespace;
/// Write a sealed-segment index file too large to materialize
/// (`entry_count * IGGY_INDEX_SIZE > SEALED_INDEX_RESIDENT_MAX_BYTES`), so
/// `resolve_sealed_start` takes the file-backed lookup path the offset
/// cursor memoizes. Entry `i` maps offset `i * 10` to position `i * 100`.
async fn write_oversized_index(dir: &std::path::Path, start_offset: u64) -> u64 {
let entry_count =
SEALED_INDEX_RESIDENT_MAX_BYTES / crate::iggy_index::IGGY_INDEX_SIZE as u64 + 1;
let mut bytes = Vec::with_capacity(
usize::try_from(entry_count).unwrap() * crate::iggy_index::IGGY_INDEX_SIZE,
);
for i in 0..entry_count {
bytes.extend_from_slice(&crate::iggy_index::IggyIndexCache::serialize(
&IggyIndex::new(i * 10, i + 1, i * 100),
));
}
let path = format!("{}/{:0>20}.index", dir.display(), start_offset);
let mut file = compio::fs::File::create(&path).await.expect("create index");
let (written, _) = file.write_all_at(bytes, 0).await.into();
written.expect("write index");
file.sync_all().await.expect("sync index");
entry_count
}
fn sizing_plan(bytes_per_message: Option<u32>, widest_batch_bytes: u64) -> DiskReadPlan {
DiskReadPlan {
partition_dir: PartitionDirResolution::NoFiles,
bytes_per_message,
widest_batch_bytes,
segments: Vec::new(),
start_position: 0,
start_index_offset: None,
namespace_raw: 0,
validate_checksum: false,
}
}
/// A batch is the unit the walk can consume, so a count-derived estimate
/// that lands under one decodes nothing and pays the quadrupling re-read.
/// A poll one message short of a producer's batch was the worst case,
/// reading about five times what a flat megabyte would have.
#[test]
fn chunk_len_never_lands_under_a_whole_batch() {
let batch = COMMAND_HEADER_SIZE as u64 + 1000 * 1000;
let mean = u32::try_from(batch / 1000).expect("mean fits");
let plan = sizing_plan(Some(mean), batch);
assert!(plan.chunk_len(999) >= batch);
assert!(plan.chunk_len(500) >= batch);
assert!(plan.chunk_len(1) >= batch);
// The floor never pushes a read above what an unsized poll would take.
assert_eq!(
sizing_plan(Some(mean), 4 << 20).chunk_len(1),
DISK_POLL_CHUNK_MAX
);
// A count wide enough to matter still wins over the floor.
assert_eq!(
sizing_plan(Some(10), 2048).chunk_len(1000),
DISK_POLL_CHUNK_MIN
);
}
#[test]
fn chunk_len_sizes_the_first_read_from_the_requested_count() {
let plan = |bytes_per_message| sizing_plan(bytes_per_message, 0);
// Nothing committed yet, so nothing bridges a count to bytes.
assert_eq!(plan(None).chunk_len(1000), DISK_POLL_CHUNK_MAX);
// A thousand small messages used to read a megabyte to return 150 KB.
assert_eq!(
plan(Some(150)).chunk_len(1000),
150 * 1000 + COMMAND_HEADER_SIZE as u64
);
// The floor keeps a poll for a few messages off a read per batch, the
// ceiling is what every poll read before it was sized at all.
assert_eq!(plan(Some(150)).chunk_len(1), DISK_POLL_CHUNK_MIN);
assert_eq!(plan(Some(64 << 10)).chunk_len(1000), DISK_POLL_CHUNK_MAX);
// Wide messages and a wide count must clamp, never wrap.
assert_eq!(
plan(Some(u32::MAX)).chunk_len(u32::MAX),
DISK_POLL_CHUNK_MAX
);
}
#[cfg(feature = "poll-diagnostics")]
#[compio::test]
async fn read_accounting_includes_failed_retry_attempts() {
let directory = tempfile::tempdir().unwrap();
let file = compio::fs::File::create(directory.path().join("empty.log"))
.await
.unwrap();
let plan = DiskReadPlan {
partition_dir: PartitionDirResolution::NoFiles,
bytes_per_message: None,
widest_batch_bytes: 0,
segments: Vec::new(),
start_position: 0,
start_index_offset: None,
namespace_raw: 0,
validate_checksum: true,
};
let mut walk = DiskWalk::starting_at(0, 0);
assert!(
plan.read_chunk_with_retry(&file, 64, &mut walk)
.await
.is_none()
);
assert_eq!(walk.chunk_reads, 3);
assert_eq!(walk.requested_bytes, 192);
assert_eq!(walk.matched, 0);
}
#[cfg(feature = "poll-diagnostics")]
#[compio::test]
async fn incomplete_batch_reread_keeps_its_exact_length_for_the_walk() {
const BATCH_COUNT: u32 = 4;
let directory = tempfile::tempdir().unwrap();
let length = disk_batch(128 << 10, 0).len();
let mut records = Vec::with_capacity(length * BATCH_COUNT as usize);
for offset in 0..BATCH_COUNT {
records.extend_from_slice(&disk_batch(128 << 10, u64::from(offset)));
}
let mut file = compio::fs::File::create(directory.path().join("batches.log"))
.await
.unwrap();
let (written, _) = file.write_all_at(records, 0).await.into();
written.unwrap();
let file = compio::fs::File::open(directory.path().join("batches.log"))
.await
.unwrap();
let plan = sizing_plan(Some(1), 0);
let mut walk = DiskWalk::starting_at(0, 0);
assert!(matches!(
plan.walk_segment(
&file,
MessageLookup::Offset {
offset: 0,
count: BATCH_COUNT,
ceiling: u64::MAX
},
BATCH_COUNT,
length as u64 * u64::from(BATCH_COUNT),
&mut walk
)
.await,
SegmentWalk::Done
));
assert_eq!(walk.matched, BATCH_COUNT);
assert_eq!(walk.chunk_reads, BATCH_COUNT + 1);
assert_eq!(
walk.requested_bytes,
DISK_POLL_CHUNK_MIN + length as u64 * u64::from(BATCH_COUNT)
);
}
#[cfg(feature = "poll-diagnostics")]
#[compio::test]
async fn oversized_batch_does_not_widen_later_reads_or_the_next_segment() {
const SMALL_BATCHES: u64 = 16;
for separate_segments in [false, true] {
let directory = tempfile::tempdir().unwrap();
let mut wide = disk_batch(3 << 20, 0);
let wide_length = wide.len() as u64;
let mut tail = Vec::new();
for offset in 1..=SMALL_BATCHES {
tail.extend_from_slice(&disk_batch(128 << 10, offset));
}
let segments = if separate_segments {
vec![wide, tail]
} else {
wide.extend_from_slice(&tail);
vec![wide]
};
let plan = sizing_plan(Some(1), 0);
let mut walk = DiskWalk::starting_at(0, 0);
for (index, records) in segments.into_iter().enumerate() {
let path = directory.path().join(format!("{index}.log"));
std::fs::write(&path, &records).unwrap();
let file = compio::fs::File::open(path).await.unwrap();
walk.position = 0;
assert!(matches!(
plan.walk_segment(
&file,
MessageLookup::Offset {
offset: 0,
count: 2,
ceiling: u64::MAX
},
2,
records.len() as u64,
&mut walk,
)
.await,
SegmentWalk::Done
));
}
assert_eq!(walk.matched, 2);
assert_eq!(walk.chunk_reads, 3);
assert_eq!(
walk.requested_bytes,
DISK_POLL_CHUNK_MIN + wide_length + DISK_POLL_CHUNK_MAX,
"one oversized batch must not raise subsequent reads above the chunk ceiling"
);
}
}
#[cfg(feature = "poll-diagnostics")]
fn disk_batch(payload_length: u32, offset: u64) -> Vec<u8> {
let mut messages = IggyMessages::with_capacity(1);
messages.push(IggyMessage {
header: IggyMessageHeader {
payload_length,
..Default::default()
},
payload: Bytes::from(vec![1; usize::try_from(payload_length).unwrap()]),
user_headers: None,
});
let mut batch =
SendMessagesOwned::from_messages(IggyNamespace::new(1, 1, 0), &messages).unwrap();
batch.header.base_offset = offset;
batch.header.batch_checksum = batch.header.checksum_for_blob(&batch.blob);
let mut record = vec![0; batch.header.total_size()];
batch.header.encode_into(&mut record);
record[COMMAND_HEADER_SIZE..].copy_from_slice(&batch.blob);
record
}
fn offset_query(offset: u64) -> MessageLookup {
MessageLookup::Offset {
offset,
count: 1,
ceiling: u64::MAX,
}
}
#[compio::test]
async fn sealed_offset_cursor_answers_in_interval_polls_without_the_index_file() {
let dir = std::env::temp_dir().join(format!(
"iggy-poll-cursor-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock after epoch")
.as_nanos(),
));
compio::fs::create_dir_all(&dir).await.expect("create dir");
write_oversized_index(&dir, 0).await;
let handle: SealedSegmentHandle = Rc::new(SealedSegmentReadState::default());
let segment = DiskSegment {
start_offset: 0,
persisted: u64::MAX,
read_state: Rc::clone(&handle),
sealed: true,
};
let plan = DiskReadPlan {
partition_dir: PartitionDirResolution::Resolved(dir.display().to_string()),
bytes_per_message: None,
widest_batch_bytes: 0,
segments: Vec::new(),
start_position: 0,
start_index_offset: None,
namespace_raw: 0,
validate_checksum: false,
};
let partition_dir = dir.display().to_string();
// First poll pays the on-file lookup and memoizes entry 2's interval
// [20, 30): offset 25 resolves to entry 2 (offset 20 -> position 200).
let first = plan
.resolve_sealed_start(&segment, offset_query(25), &partition_dir)
.await;
// The entry offset rides along so the caller can size its first read
// to cover the run between that entry and the requested offset.
assert_eq!(first, Some((200, 20)));
let cursor = handle.offset_cursor.get().expect("cursor memoized");
assert_eq!(
(cursor.offset, cursor.valid_until, cursor.position),
(20, 30, 200),
);
// Delete the index file: an in-interval re-poll must still resolve
// (proof the cursor answered with zero index-file reads)...
std::fs::remove_dir_all(&dir).expect("remove dir");
let in_interval = plan
.resolve_sealed_start(&segment, offset_query(29), &partition_dir)
.await;
assert_eq!(in_interval, Some((200, 20)));
// ...while an offset past the interval misses the cursor, reaches for
// the (now gone) file, and falls back to the byte-0 scan.
let past_interval = plan
.resolve_sealed_start(&segment, offset_query(30), &partition_dir)
.await;
assert_eq!(past_interval, None);
}
// Resident execution passes already selected bytes through unchanged.
// The snapshot test needs a nonempty payload but does not decode messages.
fn placeholder_fragments() -> PollFragments<4096> {
let mut fragments = PollFragments::new();
fragments.push(crate::types::Fragment::whole(
Owned::<4096>::zeroed(8).into(),
));
fragments
}
#[test]
fn resident_read_returns_snapshot_facts() {
let snapshot_history = PollHistoryId::default();
let partition_commit_offset = 42;
let last_selected_offset = 5;
let consumer_id = 7;
let partition_id = 0;
let resident_plan = PollPlan {
commit_offset: partition_commit_offset,
context: PollContext {
history: snapshot_history,
consumer: PollingConsumer::Consumer(consumer_id, partition_id),
auto_commit: true,
},
tier: PollTier::Resident {
fragments: placeholder_fragments(),
last_matching_offset: Some(last_selected_offset),
},
};
assert!(!resident_plan.needs_off_pump_io());
// Reading preserves both the partition frontier and the last selected
// message offset. These are snapshot facts awaiting owner acceptance.
let read_result = resident_plan.execute_resident();
assert_eq!(read_result.context.history, snapshot_history);
assert_eq!(read_result.commit_offset, partition_commit_offset);
assert_eq!(read_result.last_matching_offset, Some(last_selected_offset));
assert!(!read_result.fragments.is_empty());
}
#[test]
fn empty_read_returns_no_progress() {
let consumer_id = 7;
let partition_id = 0;
let empty_plan = PollPlan {
commit_offset: 9,
context: PollContext {
history: PollHistoryId::default(),
consumer: PollingConsumer::Consumer(consumer_id, partition_id),
auto_commit: true,
},
tier: PollTier::Empty,
};
// Automatic commits are enabled, but no matching message means there
// is no selected offset for the owner to apply as consumer progress.
let read_result = empty_plan.execute_resident();
assert!(read_result.fragments.is_empty());
assert_eq!(read_result.last_matching_offset, None);
}
}