This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 7bd69670f refactor(partitions): make purge recovery testable with
SimStorage (#4240)
7bd69670f is described below
commit 7bd69670f9ad9946f6490359443ec25bf7e2a089
Author: diegomrsantos <[email protected]>
AuthorDate: Mon Sep 21 18:37:42 2026 +0200
refactor(partitions): make purge recovery testable with SimStorage (#4240)
---
Cargo.lock | 1 +
core/journal/Cargo.toml | 1 +
core/journal/src/durable_storage.rs | 192 +++++++++++++-
core/partitions/src/iggy_partition.rs | 137 ++++++++--
core/partitions/src/offset_storage.rs | 247 ++++++++++++------
core/server/src/lib.rs | 5 +-
core/server/src/offset_recovery.rs | 179 ++++++-------
core/server/src/partition_helpers.rs | 105 ++++++--
core/simulator/src/storage.rs | 10 +-
core/simulator/src/storage/purge.rs | 469 ++++++++++++++++++++++++++++++++++
core/simulator/src/storage/tests.rs | 2 +-
11 files changed, 1119 insertions(+), 229 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 27491fac9..43b1eeb33 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8062,6 +8062,7 @@ dependencies = [
"nix",
"server_common",
"tempfile",
+ "tokio",
"tracing",
"twox-hash",
]
diff --git a/core/journal/Cargo.toml b/core/journal/Cargo.toml
index 01443d492..bfbe72c15 100644
--- a/core/journal/Cargo.toml
+++ b/core/journal/Cargo.toml
@@ -35,6 +35,7 @@ futures = { workspace = true }
iggy_binary_protocol = { workspace = true }
iggy_common = { workspace = true }
server_common = { workspace = true }
+tokio = { workspace = true }
tracing = { workspace = true }
twox-hash = { workspace = true }
diff --git a/core/journal/src/durable_storage.rs
b/core/journal/src/durable_storage.rs
index 3ac3cbcf4..fc6f148ed 100644
--- a/core/journal/src/durable_storage.rs
+++ b/core/journal/src/durable_storage.rs
@@ -22,16 +22,29 @@ use compio::fs::{File, OpenOptions};
use compio::io::{AsyncReadAtExt, AsyncWriteAtExt};
use futures::channel::oneshot;
use futures::lock::Mutex;
+use futures::{Stream, stream};
use server_common::iobuf::{Frozen, Owned};
use std::ffi::OsString;
use std::io;
-use std::path::Path;
+use std::path::{Path, PathBuf};
+use std::pin::Pin;
+use tokio::sync::{Semaphore, mpsc};
+use tracing::warn;
+
+const FILE_DIRECTORY_BUFFER: usize = 64;
+static FILE_DIRECTORY_READERS: Semaphore = Semaphore::const_new(4);
+
+/// Paths to regular files from one directory scan, followed by any scan error.
+/// Dropping the stream may cancel enumeration without waiting for its worker.
+pub type RegularFiles = Pin<Box<dyn Stream<Item = io::Result<PathBuf>>>>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OpenMode {
Read,
ReadWrite,
Create,
+ /// Create or truncate a file without requiring read permission.
+ CreateWriteOnly,
/// Create a file if absent, preserving the inode and bytes if it exists.
CreateOrOpen,
}
@@ -43,6 +56,11 @@ pub struct StorageEntry {
/// Filesystem operations whose completion and persistence order affect
recovery.
/// Implementations must preserve open file identity across rename and unlink.
+///
+/// Completing a write or filesystem mutation does not by itself guarantee
+/// survival across a crash. File sync makes file contents durable; directory
+/// sync makes changes to that directory's entries durable. Syncing a parent
+/// directory does not sync changes inside its child directories.
pub trait DurableStorage {
type File: DurableFile;
@@ -75,9 +93,38 @@ pub trait DurableStorage {
/// # Errors
/// Returns the underlying filesystem error.
fn exists(&self, path: &Path) -> impl Future<Output = io::Result<bool>>;
+ /// Check whether a path resolves to an existing target, following
symbolic links.
+ /// Backends without symbolic links can use the default existence check.
+ ///
+ /// # Errors
+ /// Returns the underlying filesystem error. Missing targets return
`false`.
+ fn exists_following_links(&self, path: &Path) -> impl Future<Output =
io::Result<bool>> {
+ self.exists(path)
+ }
/// # Errors
/// Returns an error for unreadable directories or unsupported file types.
fn entries(&self, path: &Path) -> impl Future<Output =
io::Result<Vec<StorageEntry>>>;
+ /// Enumerate regular files without including directories.
+ ///
+ /// Disk scans skip unreadable entries and unsupported file types, and
bound
+ /// both worker concurrency and buffered paths. Other backends may use
their
+ /// existing directory enumeration through the default implementation.
+ ///
+ /// # Errors
+ /// Returns an error, or yields one in the stream, if the directory cannot
be
+ /// enumerated. Dropping a stream does not make any filesystem changes
durable.
+ fn regular_files(&self, path: &Path) -> impl Future<Output =
io::Result<RegularFiles>> {
+ async move {
+ let entries = self.entries(path).await?;
+ let path = path.to_path_buf();
+ Ok(Box::pin(stream::iter(
+ entries
+ .into_iter()
+ .filter(|entry| !entry.directory)
+ .map(move |entry| Ok(path.join(entry.name))),
+ )) as RegularFiles)
+ }
+ }
/// # Errors
/// Returns the underlying filesystem error. Missing paths are accepted.
fn remove_tree(&self, path: &Path) -> impl Future<Output = io::Result<()>>;
@@ -191,9 +238,16 @@ impl DurableStorage for DiskStorage {
async fn open(&self, path: &Path, mode: OpenMode) -> io::Result<File> {
let mut options = OpenOptions::new();
- options.read(true).write(mode != OpenMode::Read);
- if matches!(mode, OpenMode::Create | OpenMode::CreateOrOpen) {
- options.create(true).truncate(mode == OpenMode::Create);
+ options
+ .read(mode != OpenMode::CreateWriteOnly)
+ .write(mode != OpenMode::Read);
+ if matches!(
+ mode,
+ OpenMode::Create | OpenMode::CreateWriteOnly |
OpenMode::CreateOrOpen
+ ) {
+ options
+ .create(true)
+ .truncate(matches!(mode, OpenMode::Create |
OpenMode::CreateWriteOnly));
}
options.open(path).await
}
@@ -226,12 +280,76 @@ impl DurableStorage for DiskStorage {
}
}
+ async fn exists_following_links(&self, path: &Path) -> io::Result<bool> {
+ match compio::fs::metadata(path).await {
+ Ok(_) => Ok(true),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
+ Err(error) => Err(error),
+ }
+ }
+
async fn entries(&self, path: &Path) -> io::Result<Vec<StorageEntry>> {
// getdents has no io_uring operation, and shard fallback pools are
disabled.
let path = path.to_path_buf();
run_blocking("iggy-directory-scan", move ||
directory_entries(&path)).await
}
+ async fn regular_files(&self, path: &Path) -> io::Result<RegularFiles> {
+ // Compio has no asynchronous directory iterator and the shard's
blocking
+ // pool is disabled. The worker owns the permit so cancellation cannot
+ // exceed the concurrency bound.
+ let permit = FILE_DIRECTORY_READERS
+ .acquire()
+ .await
+ .map_err(io::Error::other)?;
+ let (sender, receiver) = mpsc::channel(FILE_DIRECTORY_BUFFER);
+ let directory = path.to_path_buf();
+ std::thread::Builder::new()
+ .name("iggy-file-scan".to_owned())
+ .spawn(move || {
+ let _permit = permit;
+ let result = (|| {
+ // An unreadable entry must not hide the remaining files.
+ // Only opening the directory fails the scan.
+ for entry in std::fs::read_dir(&directory)? {
+ let entry = match entry {
+ Ok(entry) => entry,
+ Err(error) => {
+ warn!(path = %directory.display(), %error,
"failed to read directory entry");
+ continue;
+ }
+ };
+ let is_file = match entry.file_type() {
+ Ok(file_type) => file_type.is_file(),
+ Err(error) => {
+ warn!(path = %directory.display(), %error,
"failed to read entry type");
+ continue;
+ }
+ };
+ if is_file &&
sender.blocking_send(Ok(Some(entry.path()))).is_err() {
+ return Ok(());
+ }
+ }
+ Ok(())
+ })();
+ // Explicit completion distinguishes an empty directory from an
+ // interrupted worker. Closed receivers abandon enumeration.
+ let _ = sender.blocking_send(result.map(|()| None));
+ })?;
+ Ok(Box::pin(stream::unfold(
+ Some(receiver),
+ |receiver| async move {
+ let mut receiver = receiver?;
+ match receiver.recv().await {
+ Some(Ok(Some(path))) => Some((Ok(path), Some(receiver))),
+ Some(Ok(None)) => None,
+ Some(Err(error)) => Some((Err(error), None)),
+ None => Some((Err(io::Error::other("directory reader
stopped")), None)),
+ }
+ },
+ )))
+ }
+
async fn remove_tree(&self, path: &Path) -> io::Result<()> {
let mut pending = vec![(path.to_path_buf(), false)];
while let Some((path, visited)) = pending.pop() {
@@ -378,7 +496,10 @@ fn directory_entries(path: &Path) ->
io::Result<Vec<StorageEntry>> {
#[cfg(test)]
mod tests {
- use super::{DiskStorage, DurableFile, DurableStorage, OpenMode,
run_blocking};
+ use super::{
+ DiskStorage, DurableFile, DurableStorage, FILE_DIRECTORY_BUFFER,
OpenMode, run_blocking,
+ };
+ use futures::TryStreamExt;
use futures::channel::oneshot;
use futures::future::{Either, select};
use std::io;
@@ -387,6 +508,67 @@ mod tests {
const WORKER_TIMEOUT: Duration = Duration::from_secs(5);
+ #[compio::test]
+ async fn
given_symlink_when_target_disappears_should_report_target_absent() {
+ let directory = tempfile::tempdir().unwrap();
+ let target = directory.path().join("target");
+ let link = directory.path().join("link");
+ std::fs::write(&target, []).unwrap();
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+
+ assert!(DiskStorage.exists_following_links(&target).await.unwrap());
+ assert!(DiskStorage.exists_following_links(&link).await.unwrap());
+
+ std::fs::remove_file(&target).unwrap();
+
+ assert!(DiskStorage.exists(&link).await.unwrap());
+ assert!(!DiskStorage.exists_following_links(&link).await.unwrap());
+ }
+
+ #[compio::test]
+ async fn
given_buffered_file_scan_when_cancelled_should_release_worker_capacity() {
+ let directory = tempfile::tempdir().unwrap();
+ let entry_count = FILE_DIRECTORY_BUFFER * 2;
+ for consumer_id in 0..entry_count {
+ std::fs::write(directory.path().join(consumer_id.to_string()),
[]).unwrap();
+ }
+
+ // More cancellations than available workers expose permits retained by
+ // a worker whose receiver has gone away.
+ for _ in 0..8 {
+ let entries =
DiskStorage.regular_files(directory.path()).await.unwrap();
+ drop(entries);
+ }
+
+ let paths: Vec<_> = DiskStorage
+ .regular_files(directory.path())
+ .await
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(paths.len(), entry_count);
+ }
+
+ #[compio::test]
+ async fn given_non_regular_entries_when_scanning_files_should_skip_them() {
+ let directory = tempfile::tempdir().unwrap();
+ let regular_file = directory.path().join("1");
+ std::fs::write(®ular_file, []).unwrap();
+ std::fs::create_dir(directory.path().join("2")).unwrap();
+ std::os::unix::fs::symlink(®ular_file,
directory.path().join("3")).unwrap();
+
+ let paths: Vec<_> = DiskStorage
+ .regular_files(directory.path())
+ .await
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+
+ assert_eq!(paths, [regular_file]);
+ }
+
#[compio::test]
async fn cancelled_blocking_operation_keeps_its_permit_until_completion() {
let executor_thread = std::thread::current().id();
diff --git a/core/partitions/src/iggy_partition.rs
b/core/partitions/src/iggy_partition.rs
index 5e39d0c3e..8f7f4f100 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -24,8 +24,9 @@ use crate::log::JournalInfo;
use crate::log::SegmentedLog;
use crate::messages_writer::MessagesWriter;
use crate::offset_storage::{
- PURGE_GENERATION_FILE, delete_persisted_offset, persist_offset,
persist_offset_max,
- persist_purge_generation, read_purge_generation,
+ PURGE_GENERATION_FILE, delete_persisted_offset,
delete_persisted_offset_with_storage,
+ persist_offset, persist_offset_max, persist_purge_generation_with_storage,
+ read_purge_generation,
};
use crate::persistence::{PartitionPersistence, PersistenceCompletion,
PersistenceNotifier};
use crate::poll_plan::{
@@ -50,6 +51,7 @@ use consensus::{
replicate_frozen_to_next_in_chain, replicate_preflight,
report_uncommittable_head,
restamp_prepare_view, send_prepare_ok as send_prepare_ok_common,
verify_prepare_integrity,
};
+use futures::{StreamExt, TryStreamExt};
use iggy_binary_protocol::primitives::consumer::WireConsumer;
use iggy_binary_protocol::requests::consumer_offsets::{
DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
@@ -67,6 +69,7 @@ use iggy_common::{
TopicRuntimeOptions,
};
use journal::Journal as _;
+use journal::durable_storage::{DiskStorage, DurableStorage};
use journal::local_gate::LocalGate;
use journal::superblock::{
PingPongSuperblock, SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS,
SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS,
@@ -88,6 +91,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::hash::Hash;
use std::num::NonZeroU32;
+use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -695,10 +699,26 @@ where
/// sentinel 0 instead would make the reconciler silently re-purge and
/// destroy post-purge messages, so the boot fails loud.
pub async fn hydrate_applied_purge_generation(&mut self) -> Result<(),
IggyError> {
+ self.hydrate_applied_purge_generation_with_storage(&DiskStorage)
+ .await
+ }
+
+ /// Restore the applied generation from the filesystem used for purge
cleanup.
+ ///
+ /// Set the partition directory and its creation revision before calling
this.
+ /// A simulator must call this on a new partition after discarding its
volatile
+ /// state, so completion is recovered from storage instead of retained in
memory.
+ ///
+ /// # Errors
+ /// Propagates failures reading the marker, as the disk recovery entry
point does.
+ pub async fn hydrate_applied_purge_generation_with_storage<S:
DurableStorage>(
+ &mut self,
+ storage: &S,
+ ) -> Result<(), IggyError> {
if let Some(dir) = self.partition_dir() {
let path = format!("{dir}/{PURGE_GENERATION_FILE}");
self.applied_purge_generation =
- read_purge_generation(&path, self.created_revision).await?;
+ read_purge_generation(storage, &path,
self.created_revision).await?;
}
Ok(())
}
@@ -7814,6 +7834,46 @@ where
self.installed_frontier = None;
self.segment_checksum_cache.borrow_mut().clear();
+ self.complete_purge_with_storage(&DiskStorage, generation)
+ .await
+ }
+
+ /// Clear consumer progress and record completion after resetting message
history.
+ ///
+ /// Completion proceeds through three stages:
+ /// 1. Clear live consumer and group bookmarks, delete their files, and
sync
+ /// each offset directory so the deletions can survive power loss.
+ /// 2. Reset offset bookkeeping and prevent old journal entries from being
+ /// applied or served as messages again.
+ /// 3. Persist the purge generation before advancing the live generation,
+ /// then invalidate cached state transfer offers and restamp the
frontier.
+ ///
+ /// Message history must already be reset, as [`Self::purge`] does before
+ /// entering this phase. The caller must exclude concurrent writes
throughout.
+ /// Retry behavior remains in [`Self::purge`], including its deferral and
+ /// message reset decisions. Storage controls the offset files and
completion
+ /// marker; journal and superblock operations still use the implementations
+ /// attached to this partition.
+ ///
+ /// Offset deletion and directory sync failures are logged and completion
+ /// continues. Keeping that decision here makes a storage harness exercise
the
+ /// same failure behavior as the server. Consequently, success does not
prove
+ /// that all bookmark deletions are durable: syncing the generation
marker's
+ /// parent does not sync the separate consumer and group directories.
+ ///
+ /// # Errors
+ /// Returns [`PurgeError::GenerationNotRecorded`] if the completion marker
+ /// cannot be persisted. Cleanup is not rolled back, the applied generation
+ /// remains unchanged, and the flag that defers prepare acknowledgements is
+ /// set. The normal purge path manages that flag when retrying.
+ #[allow(clippy::too_many_lines)]
+ pub async fn complete_purge_with_storage<S: DurableStorage>(
+ &mut self,
+ storage: &S,
+ generation: u64,
+ ) -> Result<(), PurgeError> {
+ let namespace = self.namespace();
+
// Clear consumer + consumer-group offsets (memory + disk). Collect the
// file paths before deleting so the map guard is not held across an
// await.
@@ -7849,28 +7909,25 @@ where
// full reset, and an offset file the live map never held -- a
pre-purge
// op re-persisted by journal repair on a restarted replica -- would
// otherwise survive for boot to hydrate back.
- let strayed_consumers =
-
crate::state_transfer::strayed_offset_files(self.consumer_offsets_path.as_deref())
- .into_iter()
- .filter_map(|path| {
- crate::state_transfer::numeric_offset_id(&path)
- .map(|id| (ConsumerKind::Consumer, id, path))
- });
- let strayed_groups = crate::state_transfer::strayed_offset_files(
+ let strayed_consumers = purge_offset_files(
+ storage,
+ self.consumer_offsets_path.as_deref(),
+ ConsumerKind::Consumer,
+ )
+ .await;
+ let strayed_groups = purge_offset_files(
+ storage,
self.consumer_group_offsets_path.as_deref(),
+ ConsumerKind::ConsumerGroup,
)
- .into_iter()
- .filter_map(|path| {
- crate::state_transfer::numeric_offset_id(&path)
- .map(|id| (ConsumerKind::ConsumerGroup, id, path))
- });
+ .await;
for (kind, consumer_id, path) in consumer_paths
.into_iter()
.chain(group_paths)
.chain(strayed_consumers)
.chain(strayed_groups)
{
- if let Err(error) = delete_persisted_offset(&path).await {
+ if let Err(error) = delete_persisted_offset_with_storage(storage,
&path).await {
self.consumer_offset_capacity_for(kind)
.record_stranded(consumer_id);
warn!(
@@ -7904,7 +7961,7 @@ where
.into_iter()
.chain(self.consumer_group_offsets_path.clone())
{
- if let Err(error) = crate::state_transfer::fsync_dir(&dir).await {
+ if let Err(error) = storage.sync_directory(Path::new(&dir)).await {
warn!(
target: "iggy.partitions.diag",
plane = "partitions",
@@ -7980,8 +8037,13 @@ where
// recorded the generation keep it.
if let Some(dir) = self.partition_dir() {
let path = format!("{dir}/{PURGE_GENERATION_FILE}");
- if let Err(error) =
- persist_purge_generation(&path, generation,
self.created_revision).await
+ if let Err(error) = persist_purge_generation_with_storage(
+ storage,
+ &path,
+ generation,
+ self.created_revision,
+ )
+ .await
{
self.purge_deferred = true;
warn!(
@@ -8416,6 +8478,41 @@ where
}
}
+async fn purge_offset_files<S: DurableStorage>(
+ storage: &S,
+ directory: Option<&str>,
+ kind: ConsumerKind,
+) -> Vec<(ConsumerKind, u32, String)> {
+ let Some(directory) = directory else {
+ return Vec::new();
+ };
+ let entries =
futures::stream::once(storage.regular_files(Path::new(directory))).try_flatten();
+ futures::pin_mut!(entries);
+ let mut offsets = Vec::new();
+ while let Some(entry) = entries.next().await {
+ let path = match entry {
+ Ok(path) => path,
+ Err(error) => {
+ warn!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ path = directory,
+ %error,
+ "failed to scan consumer offset directory during purge"
+ );
+ continue;
+ }
+ };
+ let Some(path) = path.to_str() else {
+ continue;
+ };
+ if let Some(consumer_id) =
crate::state_transfer::numeric_offset_id(path) {
+ offsets.push((kind, consumer_id, path.to_owned()));
+ }
+ }
+ offsets
+}
+
/// Automatic commits remain monotone because an earlier poll can commit after
/// a later one. Explicit stores may intentionally rewind the cursor.
fn upsert_committed_offset<K>(
diff --git a/core/partitions/src/offset_storage.rs
b/core/partitions/src/offset_storage.rs
index aaf341412..266f5a280 100644
--- a/core/partitions/src/offset_storage.rs
+++ b/core/partitions/src/offset_storage.rs
@@ -15,12 +15,25 @@
// specific language governing permissions and limitations
// under the License.
+//! Store consumer bookmarks and the partition's applied purge generation.
+//!
+//! A bookmark records the last consumed offset. The purge marker instead
records
+//! which reset was applied to this incarnation of the partition. Recovery uses
+//! them to restore progress and decide whether a purge must be repeated.
+//!
+//! Functions ending in `_with_storage` share the persistence sequence between
+//! real disk and simulated storage. File sync makes record contents durable;
+//! directory sync makes creation, replacement, or deletion durable. Offset
callers
+//! own that directory sync, while purge marker writes include it before
returning.
+
+use std::{io, path::Path};
+
use compio::{
- fs::{OpenOptions, create_dir_all, remove_file, rename},
- io::{AsyncReadAt, AsyncReadAtExt, AsyncWriteAtExt},
+ fs::{OpenOptions, remove_file, rename},
+ io::{AsyncReadAt, AsyncWriteAtExt},
};
use iggy_common::{IggyError, calculate_checksum};
-use std::{io, path::Path};
+use journal::durable_storage::{DiskStorage, DurableFile, DurableStorage,
OpenMode};
use tracing::warn;
const OFFSET_SIZE: usize = core::mem::size_of::<u64>();
@@ -122,11 +135,28 @@ pub fn decode_offset_record(bytes: &[u8]) -> OffsetRecord
{
/// # Errors
/// [`IggyError`] when the directory, file, or write cannot be created or
completed.
pub async fn persist_offset(path: &str, offset: u64, persisted: bool) ->
Result<(), IggyError> {
+ persist_offset_with_storage(&DiskStorage, path, offset, persisted).await
+}
+
+/// Persist a consumer offset through the supplied storage backend.
+///
+/// Uses the same record and replacement policy as [`persist_offset`]. When
+/// `persisted` is true, the caller must still sync the parent directory before
+/// treating the replacement name as durable. Calls for one path must be
serialized.
+///
+/// # Errors
+/// Returns the directory, open, or write error from [`persist_offset`].
+pub async fn persist_offset_with_storage<S: DurableStorage>(
+ storage: &S,
+ path: &str,
+ offset: u64,
+ persisted: bool,
+) -> Result<(), IggyError> {
let record = encode_offset_record(offset);
if persisted {
- replace_file(path, record, true, false).await
+ replace_file(storage, path, record, true, false).await
} else {
- write_in_place(path, record).await
+ write_in_place(storage, path, record).await
}
}
@@ -160,28 +190,33 @@ pub async fn persist_offset_retained(
Ok((result, file))
}
-async fn write_in_place<const N: usize>(path: &str, record: [u8; N]) ->
Result<(), IggyError> {
- create_parent_dir(path).await?;
- let mut file = OpenOptions::new()
- .write(true)
- .create(true)
- .truncate(true)
- .open(path)
+async fn write_in_place<S: DurableStorage, const N: usize>(
+ storage: &S,
+ path: &str,
+ record: [u8; N],
+) -> Result<(), IggyError> {
+ create_parent_dir_with_storage(storage, path).await?;
+ let mut file = storage
+ .open(Path::new(path), OpenMode::CreateWriteOnly)
.await
.map_err(|_|
IggyError::CannotOpenConsumerOffsetsFile(path.to_owned()))?;
- file.write_all_at(record, 0)
+ file.write(0, record.to_vec())
.await
- .0
.map_err(|_| IggyError::CannotWriteToFile)
}
async fn create_parent_dir(path: &str) -> Result<(), IggyError> {
- // No `exists()` probe first: that is a BLOCKING `std::path` stat on the
pump
- // in front of every write, which serialises a batched fan-out on stats
- // before it can submit any I/O. `create_dir_all` is already a no-op on an
- // existing directory.
+ create_parent_dir_with_storage(&DiskStorage, path).await
+}
+
+async fn create_parent_dir_with_storage<S: DurableStorage>(
+ storage: &S,
+ path: &str,
+) -> Result<(), IggyError> {
+ // Probing with `Path::exists()` blocks the pump before it can submit
writes.
+ // Creating an existing directory already succeeds without changing it.
if let Some(parent) = Path::new(path).parent() {
- create_dir_all(parent).await.map_err(|_| {
+ storage.create_directories(parent).await.map_err(|_| {
IggyError::CannotCreateConsumerOffsetsDirectory(parent.display().to_string())
})?;
}
@@ -190,8 +225,8 @@ async fn create_parent_dir(path: &str) -> Result<(),
IggyError> {
pub(crate) async fn stage_offset_replacement(path: &str, offset: u64) ->
Result<(), IggyError> {
// Install can remove old files before publishing replacements. Staging
- // must survive a crash regardless of the normal consumer-offset
durability policy.
- write_replacement(path, encode_offset_record(offset), true)
+ // must survive a crash regardless of the normal consumer offset
durability policy.
+ write_replacement(&DiskStorage, path, encode_offset_record(offset), true)
.await
.map(|_| ())
}
@@ -206,23 +241,25 @@ pub(crate) async fn discard_offset_replacement(path:
&str) {
let _ = remove_file(replacement_path(path)).await;
}
-async fn replace_file<const N: usize>(
+async fn replace_file<S: DurableStorage, const N: usize>(
+ storage: &S,
path: &str,
record: [u8; N],
persisted: bool,
sync_parent: bool,
) -> Result<(), IggyError> {
- let temporary = write_replacement(path, record, persisted).await?;
- if rename(&temporary, path).await.is_err() {
- let _ = remove_file(&temporary).await;
+ let temporary = write_replacement(storage, path, record, persisted).await?;
+ if storage
+ .rename(Path::new(&temporary), Path::new(path))
+ .await
+ .is_err()
+ {
+ let _ = storage.remove_file(Path::new(&temporary)).await;
return Err(IggyError::CannotWriteToFile);
}
if sync_parent && let Some(parent) = Path::new(path).parent() {
- let parent = compio::fs::File::open(parent)
- .await
- .map_err(|_| IggyError::CannotSyncFile)?;
- parent
- .sync_all()
+ storage
+ .sync_directory(parent)
.await
.map_err(|_| IggyError::CannotSyncFile)?;
}
@@ -230,32 +267,30 @@ async fn replace_file<const N: usize>(
Ok(())
}
-async fn write_replacement<const N: usize>(
+async fn write_replacement<S: DurableStorage, const N: usize>(
+ storage: &S,
path: &str,
record: [u8; N],
persisted: bool,
) -> Result<String, IggyError> {
- create_parent_dir(path).await?;
+ create_parent_dir_with_storage(storage, path).await?;
// Keep the previous cursor intact until the complete replacement exists.
- // A failed truncate-and-write otherwise turns a valid cursor into a torn
+ // A failed write after truncation otherwise turns a valid cursor into a
torn
// file that boot discards. The fixed sibling is safe because writes to one
// consumer key are serialized by the partition pump.
let temporary = replacement_path(path);
- let mut file = OpenOptions::new()
- .write(true)
- .create(true)
- .truncate(true)
- .open(&temporary)
+ let mut file = storage
+ .open(Path::new(&temporary), OpenMode::CreateWriteOnly)
.await
.map_err(|_|
IggyError::CannotOpenConsumerOffsetsFile(path.to_owned()))?;
- if file.write_all_at(record, 0).await.0.is_err() {
- let _ = remove_file(&temporary).await;
+ if file.write(0, record.to_vec()).await.is_err() {
+ let _ = storage.remove_file(Path::new(&temporary)).await;
return Err(IggyError::CannotWriteToFile);
}
- if persisted && file.sync_data().await.is_err() {
- let _ = remove_file(&temporary).await;
+ if persisted && file.sync().await.is_err() {
+ let _ = storage.remove_file(Path::new(&temporary)).await;
return Err(IggyError::CannotWriteToFile);
}
drop(file);
@@ -346,61 +381,90 @@ pub async fn read_offset_max(path: &str, offset: u64) ->
Result<PersistedOffset,
/// Durably record the purge generation a partition has locally applied, keyed
/// to the incarnation (`created_revision`) it was applied for.
///
-/// Atomic replacement like [`persist_offset`] but ALWAYS data-synced,
regardless of
-/// the consumer-offset durability policy: purges are rare, the record is 16
bytes, and
+/// Atomic replacement like [`persist_offset`] but always synced, regardless of
+/// the consumer offset durability policy: purges are rare, the record is 16
bytes, and
/// a generation lost from the page cache in a crash makes the reconciler
-/// re-purge on restart, wiping messages appended after the purge. A failure
-/// leaves the previous record on disk so the caller keeps its in-memory
-/// applied generation old and retries.
+/// repeat the purge on restart, wiping messages appended after the purge.
+/// The parent directory is synced after the replacement is renamed into place.
+/// A failure before rename preserves the previous record. If the directory
sync
+/// fails, the replacement is visible but its survival across a crash is
uncertain.
///
/// # Errors
-/// Propagates the underlying open/write/sync failure.
+/// Propagates the underlying open, write, or sync failure.
pub async fn persist_purge_generation(
path: &str,
generation: u64,
created_revision: u64,
+) -> Result<(), IggyError> {
+ persist_purge_generation_with_storage(&DiskStorage, path, generation,
created_revision).await
+}
+
+/// Persist a purge completion marker through the supplied storage backend.
+///
+/// The record includes the partition incarnation. Both the replacement file
and
+/// its parent directory are synced as in [`persist_purge_generation`]. Calls
for
+/// one path must be serialized because they share a temporary filename.
+///
+/// # Errors
+/// Returns the directory, open, write, or sync error from
[`persist_purge_generation`].
+pub async fn persist_purge_generation_with_storage<S: DurableStorage>(
+ storage: &S,
+ path: &str,
+ generation: u64,
+ created_revision: u64,
) -> Result<(), IggyError> {
let mut record = [0u8; PURGE_GENERATION_RECORD_SIZE];
record[..OFFSET_SIZE].copy_from_slice(&generation.to_le_bytes());
record[OFFSET_SIZE..].copy_from_slice(&created_revision.to_le_bytes());
- replace_file(path, record, true, true).await
+ replace_file(storage, path, record, true, true).await
}
/// Read the purge generation this replica applied for the `created_revision`
-/// incarnation of the partition.
+/// incarnation of the partition through the supplied storage backend.
///
-/// Absent and torn files map to `Ok(0)`: both imply a purge died mid-write,
and
-/// `0` makes the reconciler re-apply the purge, the correct self-healing
-/// recovery for an idempotent wipe.
+/// Absent and torn files map to `Ok(0)`, which makes the reconciler apply any
+/// committed purge again. A failed existence probe is logged and treated as
absence.
///
-/// A record written for a DIFFERENT incarnation maps to `Ok(0)` too. A failed
+/// A record written for a different incarnation maps to `Ok(0)` too. A failed
/// `delete_partitions_from_disk` leaves the directory (and this file) behind;
/// the recreated topic's generations restart at 0, so hydrating the dead
/// incarnation's generation would swallow every purge of the new topic until
/// the committed counter climbed past it.
///
-/// A real I/O error propagates instead: collapsing it to `0` would re-purge a
+/// An open or read error propagates. Collapsing it to `0` would purge a
/// partition whose durable generation is intact but momentarily unreadable,
/// destroying every message appended after that purge.
///
/// # Errors
-/// Propagates a real open/read failure (anything but absent or short).
-pub async fn read_purge_generation(path: &str, created_revision: u64) ->
Result<u64, IggyError> {
- if !Path::new(path).exists() {
- return Ok(0);
+/// Propagates an open or read failure after the existence probe, except a
short read.
+pub async fn read_purge_generation<S: DurableStorage>(
+ storage: &S,
+ path: &str,
+ created_revision: u64,
+) -> Result<u64, IggyError> {
+ match storage.exists_following_links(Path::new(path)).await {
+ Ok(true) => {}
+ Ok(false) => return Ok(0),
+ Err(error) => {
+ warn!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ path,
+ %error,
+ "failed to check purge generation file, treating it as absent"
+ );
+ return Ok(0);
+ }
}
- let file = OpenOptions::new()
- .read(true)
- .open(path)
+ let file = storage
+ .open(Path::new(path), OpenMode::Read)
.await
.map_err(|_|
IggyError::CannotOpenConsumerOffsetsFile(path.to_owned()))?;
- let buf = vec![0u8; PURGE_GENERATION_RECORD_SIZE];
- let compio::BufResult(read, buf) = file.read_exact_at(buf, 0).await;
- match read {
- Ok(()) => {}
+ let buf = match file.read(0, PURGE_GENERATION_RECORD_SIZE).await {
+ Ok(buf) => buf,
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof =>
return Ok(0),
Err(_) => return
Err(IggyError::CannotReadConsumerOffsets(path.to_owned())),
- }
+ };
let (generation_bytes, revision_bytes) = buf.split_at(OFFSET_SIZE);
let generation = u64::from_le_bytes(
generation_bytes
@@ -458,10 +522,24 @@ async fn read_offset_record(path: &str) ->
Result<Option<OffsetRecord>, IggyErro
/// # Errors
/// Returns [`IggyError::CannotDeleteConsumerOffsetFile`] if the unlink fails.
pub async fn delete_persisted_offset(path: &str) -> Result<bool, IggyError> {
+ delete_persisted_offset_with_storage(&DiskStorage, path).await
+}
+
+/// Unlink a consumer offset through the supplied storage backend.
+///
+/// Returns `false` when already absent. No directory sync runs here, so the
caller
+/// must sync the parent directory before treating a successful removal as
durable.
+///
+/// # Errors
+/// Returns [`IggyError::CannotDeleteConsumerOffsetFile`] if unlinking fails.
+pub async fn delete_persisted_offset_with_storage<S: DurableStorage>(
+ storage: &S,
+ path: &str,
+) -> Result<bool, IggyError> {
// NotFound is tolerated on the result instead of probed for: the probe was
// a blocking stat on the pump before every unlink, and "already gone" is
// exactly the outcome this wants anyway.
- match remove_file(path).await {
+ match storage.remove_file(Path::new(path)).await {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound =>
Ok(false),
Err(_) =>
Err(IggyError::CannotDeleteConsumerOffsetFile(path.to_owned())),
@@ -677,7 +755,9 @@ mod tests {
.into_owned();
assert_eq!(
- read_purge_generation(&path, 11).await.expect("absent file"),
+ read_purge_generation(&DiskStorage, &path, 11)
+ .await
+ .expect("absent file"),
0,
"absent file is 0"
);
@@ -686,14 +766,18 @@ mod tests {
.await
.expect("persist generation");
assert_eq!(
- read_purge_generation(&path, 11).await.expect("valid file"),
+ read_purge_generation(&DiskStorage, &path, 11)
+ .await
+ .expect("valid file"),
3,
"round-trip"
);
std::fs::write(&path, [0xAB, 0xCD]).expect("write torn file");
assert_eq!(
- read_purge_generation(&path, 11).await.expect("torn file"),
+ read_purge_generation(&DiskStorage, &path, 11)
+ .await
+ .expect("torn file"),
0,
"torn file degrades to 0 so the reconciler re-applies the purge"
);
@@ -701,7 +785,7 @@ mod tests {
// A directory path is a real I/O error, not a short read: it must
// surface, not collapse to the re-purge sentinel (a silent re-purge
// would destroy post-purge messages).
- let result = read_purge_generation(&dir.to_string_lossy(), 11).await;
+ let result = read_purge_generation(&DiskStorage,
&dir.to_string_lossy(), 11).await;
assert!(
matches!(result, Err(IggyError::CannotReadConsumerOffsets(_))),
"real I/O error must propagate, got {result:?}",
@@ -728,12 +812,16 @@ mod tests {
.expect("persist generation");
assert_eq!(
- read_purge_generation(&path, 41).await.expect("same dir"),
+ read_purge_generation(&DiskStorage, &path, 41)
+ .await
+ .expect("same dir"),
9,
"the incarnation that wrote it still hydrates it"
);
assert_eq!(
- read_purge_generation(&path, 42).await.expect("stale file"),
+ read_purge_generation(&DiskStorage, &path, 42)
+ .await
+ .expect("stale file"),
0,
"a record from a dead incarnation must not fence the new one"
);
@@ -742,9 +830,16 @@ mod tests {
persist_purge_generation(&path, 1, 42)
.await
.expect("persist generation");
- assert_eq!(read_purge_generation(&path, 42).await.expect("rekeyed"),
1);
assert_eq!(
- read_purge_generation(&path, 41).await.expect("now stale"),
+ read_purge_generation(&DiskStorage, &path, 42)
+ .await
+ .expect("rekeyed"),
+ 1
+ );
+ assert_eq!(
+ read_purge_generation(&DiskStorage, &path, 41)
+ .await
+ .expect("now stale"),
0
);
diff --git a/core/server/src/lib.rs b/core/server/src/lib.rs
index b6ff9b467..a73da7b44 100644
--- a/core/server/src/lib.rs
+++ b/core/server/src/lib.rs
@@ -33,7 +33,8 @@ pub const SEMANTIC_VERSION: SemanticVersion =
SemanticVersion::parse_const(VERSI
// Visibility rule: `pub` = named external consumer. main.rs consumes `boot`
// (including `boot::systemd`) and `server_error`; the simulator consumes
// `shell`, `boot::wire_shell_handlers`, and (through `ShellHandlers.sessions`)
-// `session_manager`. Everything else is crate-internal.
+// `session_manager`, plus the storage abstraction for offset recovery
reexported
+// below. Everything else is internal to the crate.
// boot: process entry, shard threads, recovery orchestration.
pub mod boot;
@@ -71,3 +72,5 @@ pub(crate) mod partition_helpers;
pub(crate) mod segment_recovery;
pub mod server_error;
pub(crate) mod sysinfo_probe;
+
+pub use partition_helpers::configure_consumer_offsets_with_storage;
diff --git a/core/server/src/offset_recovery.rs
b/core/server/src/offset_recovery.rs
index 40dca3ff8..25c4ca5d2 100644
--- a/core/server/src/offset_recovery.rs
+++ b/core/server/src/offset_recovery.rs
@@ -26,17 +26,18 @@
//! ways: it reads the first eight bytes and stops, and a file it wrote itself
decodes
//! here as unchecksummed.
+use std::path::Path;
+use std::sync::atomic::AtomicU64;
+
+use futures::StreamExt;
use iggy_common::{ConsumerGroupId, ConsumerKind, ConsumerOffset, IggyError};
+#[cfg(test)]
+use journal::durable_storage::DiskStorage;
+use journal::durable_storage::{DurableFile, DurableStorage, OpenMode};
use partitions::offset_storage::{OffsetRecord, decode_offset_record,
offset_replacement_id};
-use std::path::PathBuf;
-use std::sync::atomic::AtomicU64;
-use tokio::sync::{Semaphore, mpsc};
use tracing::{error, trace, warn};
const COMPONENT: &str = "STREAMING_PARTITIONS";
-const OFFSET_DIRECTORY_BUFFER: usize = 64;
-static OFFSET_DIRECTORY_READERS: Semaphore = Semaphore::const_new(4);
-type OffsetDirectoryEntries = mpsc::Receiver<std::io::Result<Option<PathBuf>>>;
pub struct RecoveredOffsets<T> {
pub entries: Vec<T>,
@@ -58,40 +59,75 @@ impl<T> Default for RecoveredOffsets<T> {
}
}
+#[cfg(test)]
pub async fn load_consumer_offsets(
path: &str,
) -> Result<RecoveredOffsets<ConsumerOffset>, IggyError> {
- let mut recovered = load_offsets(path, ConsumerKind::Consumer, |offset|
offset).await?;
+ load_consumer_offsets_with_storage(&DiskStorage, path).await
+}
+
+/// Recover consumer records, ordered by consumer ID, from a storage backend.
+/// Invalid records are removed when possible. Unreadable records or removals
+/// that cannot be made durable retain their IDs in `stranded_ids`.
+///
+/// # Errors
+/// Returns [`IggyError::CannotReadConsumerOffsets`] if the directory cannot be
+/// enumerated, including when it is missing.
+pub async fn load_consumer_offsets_with_storage<S: DurableStorage>(
+ storage: &S,
+ path: &str,
+) -> Result<RecoveredOffsets<ConsumerOffset>, IggyError> {
+ let mut recovered =
+ load_offsets(storage, path, ConsumerKind::Consumer, |offset|
offset).await?;
recovered.entries.sort_by_key(|offset| offset.consumer_id);
Ok(recovered)
}
+#[cfg(test)]
pub async fn load_consumer_group_offsets(
path: &str,
) -> Result<RecoveredOffsets<(ConsumerGroupId, ConsumerOffset)>, IggyError> {
- load_offsets(path, ConsumerKind::ConsumerGroup, |offset| {
+ load_consumer_group_offsets_with_storage(&DiskStorage, path).await
+}
+
+/// Recover group records with the same cleanup and stranded file handling as
+/// [`load_consumer_offsets_with_storage`].
+///
+/// # Errors
+/// Returns [`IggyError::CannotReadConsumerOffsets`] if the directory cannot be
+/// enumerated, including when it is missing.
+pub async fn load_consumer_group_offsets_with_storage<S: DurableStorage>(
+ storage: &S,
+ path: &str,
+) -> Result<RecoveredOffsets<(ConsumerGroupId, ConsumerOffset)>, IggyError> {
+ load_offsets(storage, path, ConsumerKind::ConsumerGroup, |offset| {
(ConsumerGroupId(offset.consumer_id as usize), offset)
})
.await
}
-async fn load_offsets<T>(
+async fn load_offsets<S: DurableStorage, T>(
+ storage: &S,
path: &str,
kind: ConsumerKind,
construct: impl Fn(ConsumerOffset) -> T,
) -> Result<RecoveredOffsets<T>, IggyError> {
trace!(?kind, path, "loading consumer offsets");
- let mut dir_entries = offset_directory_entries(path).await?;
+ let mut dir_entries = storage
+ .regular_files(Path::new(path))
+ .await
+ .map_err(|error| {
+ warn!(?kind, path, %error, "failed to enumerate offset directory");
+ IggyError::CannotReadConsumerOffsets(path.to_owned())
+ })?;
let mut recovered = RecoveredOffsets::default();
- loop {
- let entry_path = match dir_entries.recv().await {
- Some(Ok(Some(path))) => path,
- Some(Ok(None)) => break,
- Some(Err(error)) => {
+ while let Some(entry) = dir_entries.next().await {
+ let entry_path = match entry {
+ Ok(path) => path,
+ Err(error) => {
warn!(?kind, path, %error, "failed to enumerate offset
directory");
return
Err(IggyError::CannotReadConsumerOffsets(path.to_owned()));
}
- None => return
Err(IggyError::CannotReadConsumerOffsets(path.to_owned())),
};
let name = entry_path
.file_name()
@@ -99,7 +135,7 @@ async fn load_offsets<T>(
.to_string_lossy()
.into_owned();
if offset_replacement_id(&name).is_some() {
- remove_stale_replacement(&entry_path, &name).await;
+ remove_stale_replacement(storage, &entry_path, &name).await;
continue;
}
let Ok(consumer_id) = name.parse::<u32>() else {
@@ -113,7 +149,7 @@ async fn load_offsets<T>(
error!(?kind, name, "invalid consumer offset path");
continue;
};
- let offset = match read_offset_file(&path,
offset_kind_label(kind)).await {
+ let offset = match read_offset_file(storage, &path,
offset_kind_label(kind)).await {
OffsetFileLoad::Loaded(offset) => offset,
OffsetFileLoad::Removed => continue,
OffsetFileLoad::Stranded => {
@@ -131,62 +167,11 @@ async fn load_offsets<T>(
Ok(recovered)
}
-async fn offset_directory_entries(path: &str) ->
Result<OffsetDirectoryEntries, IggyError> {
- // Compio has no asynchronous directory iterator and the shard's blocking
- // pool is disabled. Bound both OS threads and buffered paths. The worker
- // owns the permit so cancellation cannot exceed the concurrency bound.
- let permit = OFFSET_DIRECTORY_READERS
- .acquire()
- .await
- .map_err(|_| IggyError::CannotReadConsumerOffsets(path.to_owned()))?;
- let (sender, receiver) = mpsc::channel(OFFSET_DIRECTORY_BUFFER);
- let directory = path.to_owned();
- std::thread::Builder::new()
- .name("iggy-offset-recovery".to_owned())
- .spawn(move || {
- let _permit = permit;
- let result = (|| {
- // Only the directory read itself is fatal. One unreadable
- // entry is skipped with a warning, as the on-reactor loader
- // did, so a single bad dirent cannot keep a partition from
- // booting.
- for entry in std::fs::read_dir(&directory)? {
- let entry = match entry {
- Ok(entry) => entry,
- Err(error) => {
- warn!(path = directory, %error, "failed to read
offset directory entry");
- continue;
- }
- };
- let is_file = match entry.file_type() {
- Ok(file_type) => file_type.is_file(),
- Err(error) => {
- warn!(path = directory, %error, "failed to read
offset entry type");
- continue;
- }
- };
- if is_file &&
sender.blocking_send(Ok(Some(entry.path()))).is_err() {
- return Ok(());
- }
- }
- Ok(())
- })();
- // Explicit completion distinguishes an empty directory from an
- // interrupted worker. Closed receivers simply abandon enumeration.
- let _ = sender.blocking_send(result.map(|()| None));
- })
- .map_err(|error| {
- error!(path, %error, "failed to start offset directory reader");
- IggyError::CannotReadConsumerOffsets(path.to_owned())
- })?;
- Ok(receiver)
-}
-
/// A crashed atomic replacement leaves its sibling behind. The rename never
/// landed, so the sibling is never authoritative. Removal needs no directory
/// sync because a resurrected sibling is still ignored on the next load.
-async fn remove_stale_replacement(path: &std::path::Path, name: &str) {
- match compio::fs::remove_file(path).await {
+async fn remove_stale_replacement<S: DurableStorage>(storage: &S, path: &Path,
name: &str) {
+ match storage.remove_file(path).await {
Ok(()) => trace!("Removed stale offset replacement file: '{name}'."),
Err(e) => warn!(
"{COMPONENT} (error: {e}) - could not remove stale offset
replacement \
@@ -202,8 +187,18 @@ const fn offset_kind_label(kind: ConsumerKind) -> &'static
str {
}
}
-async fn read_offset_file(path: &str, offset_kind: &'static str) ->
OffsetFileLoad {
- let bytes = match compio::fs::read(path).await {
+async fn read_offset_file<S: DurableStorage>(
+ storage: &S,
+ path: &str,
+ offset_kind: &'static str,
+) -> OffsetFileLoad {
+ let bytes = match async {
+ let file = storage.open(Path::new(path), OpenMode::Read).await?;
+ let length =
usize::try_from(file.length().await?).map_err(std::io::Error::other)?;
+ file.read(0, length).await
+ }
+ .await
+ {
Ok(bytes) => bytes,
Err(e) => {
warn!(
@@ -220,7 +215,7 @@ async fn read_offset_file(path: &str, offset_kind: &'static
str) -> OffsetFileLo
"{COMPONENT} - failed to read {offset_kind} from file
(truncated), \
path: {path}, removing invalid file."
);
- remove_invalid_offset_file(path, offset_kind).await
+ remove_invalid_offset_file(storage, path, offset_kind).await
}
// Skipped rather than loaded: resuming from a cursor provably not the
one
// written reads as ordinary redelivery or a gap, never as corruption.
@@ -238,28 +233,27 @@ async fn read_offset_file(path: &str, offset_kind:
&'static str) -> OffsetFileLo
(offset: {offset}, expected: {expected}, found: {found}), \
path: {path}, removing it and resuming this consumer from the
start."
);
- remove_invalid_offset_file(path, offset_kind).await
+ remove_invalid_offset_file(storage, path, offset_kind).await
}
}
}
-async fn remove_invalid_offset_file(path: &str, offset_kind: &'static str) ->
OffsetFileLoad {
- if let Err(error) = compio::fs::remove_file(path).await {
+async fn remove_invalid_offset_file<S: DurableStorage>(
+ storage: &S,
+ path: &str,
+ offset_kind: &'static str,
+) -> OffsetFileLoad {
+ if let Err(error) = storage.remove_file(Path::new(path)).await {
error!(
"{COMPONENT} (error: {error}) - could not remove the invalid \
{offset_kind} file, path: {path}; remove it manually."
);
return OffsetFileLoad::Stranded;
}
- let Some(parent) = std::path::Path::new(path).parent() else {
+ let Some(parent) = Path::new(path).parent() else {
return OffsetFileLoad::Removed;
};
- match async {
- let directory = compio::fs::File::open(parent).await?;
- directory.sync_all().await
- }
- .await
- {
+ match storage.sync_directory(parent).await {
Ok(()) => OffsetFileLoad::Removed,
Err(error) => {
error!(
@@ -285,27 +279,13 @@ mod tests {
));
}
- #[compio::test]
- async fn
given_full_directory_buffer_when_loader_is_cancelled_should_release_worker_capacity()
{
- let dir = tempfile::tempdir().unwrap();
- for id in 0..OFFSET_DIRECTORY_BUFFER * 2 {
- std::fs::write(dir.path().join(id.to_string()),
0_u64.to_le_bytes()).unwrap();
- }
- let path = dir.path().to_str().unwrap();
- for _ in 0..8 {
- let entries = offset_directory_entries(path).await.unwrap();
- drop(entries);
- }
- let loaded = load_consumer_offsets(path).await.unwrap();
- assert_eq!(loaded.entries.len(), OFFSET_DIRECTORY_BUFFER * 2);
- }
-
#[compio::test]
async fn
given_numeric_directory_and_torn_file_when_loading_should_remove_only_invalid_file()
{
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("7")).unwrap();
std::fs::write(dir.path().join("8"), [1, 2]).unwrap();
std::fs::write(dir.path().join("9"), 12_u64.to_le_bytes()).unwrap();
+ std::fs::write(dir.path().join("10"), []).unwrap();
std::fs::write(dir.path().join("9.tmp"), [0_u8; 4]).unwrap();
std::fs::write(dir.path().join("notes.tmp"), b"unrelated").unwrap();
let path = dir.path().to_str().unwrap();
@@ -316,6 +296,7 @@ mod tests {
assert_eq!(consumers.entries[0].consumer_id, 9);
assert!(consumers.stranded_ids.is_empty());
assert!(!dir.path().join("8").exists());
+ assert!(!dir.path().join("10").exists());
let groups = load_consumer_group_offsets(path).await.unwrap();
assert_eq!(groups.entries.len(), 1);
assert_eq!(groups.entries[0].0, ConsumerGroupId(9));
diff --git a/core/server/src/partition_helpers.rs
b/core/server/src/partition_helpers.rs
index 121df8de7..d7ecfd798 100644
--- a/core/server/src/partition_helpers.rs
+++ b/core/server/src/partition_helpers.rs
@@ -30,7 +30,7 @@
//! partition yet.
use crate::offset_recovery::{
- RecoveredOffsets, load_consumer_group_offsets, load_consumer_offsets,
+ RecoveredOffsets, load_consumer_group_offsets_with_storage,
load_consumer_offsets_with_storage,
};
use crate::segment_recovery::{
RecoveredSegment, load_persisted_segments,
load_persisted_segments_with_checkpoint,
@@ -140,9 +140,9 @@ pub async fn create_partition_file_hierarchy(
Ok(())
}
-/// Populate `partition` with consumer-offset / consumer-group-offset storage.
+/// Populate `partition` with consumer and consumer group offset storage from
disk.
///
-/// Hydrates from on-disk state if files exist (recovery path) or
+/// Hydrates from state on disk if files exist (recovery path) or
/// configures empty maps (fresh partition path). Recovered offsets are bounded
/// so a partition that lost its tail does not surface consumer offsets ahead
of
/// an offset it never handed out, and `current_offset` is where a bounded one
@@ -150,15 +150,50 @@ pub async fn create_partition_file_hierarchy(
///
/// # Errors
///
-/// Returns [`ServerError::ConsumerOffsetsLoad`] when the on-disk files
-/// exist but fail to decode. A stored offset past the offset space is clamped
+/// Returns [`ServerError::ConsumerOffsetsLoad`] when an existing offset
+/// directory cannot be enumerated. A stored offset past the offset space is
clamped
/// to `current_offset` (with a warning), not an error.
-#[allow(clippy::too_many_lines)]
pub async fn configure_consumer_offsets(
partition: &mut IggyPartition<Rc<IggyMessageBus>>,
config: &ServerConfig,
namespace: IggyNamespace,
current_offset: u64,
+) -> Result<(), ServerError> {
+ configure_consumer_offsets_with_storage(
+ &DiskStorage,
+ partition,
+ config,
+ namespace,
+ current_offset,
+ )
+ .await
+}
+
+/// Recover consumer and group offsets from `storage` into a new partition.
+///
+/// Restore the partition's message offset and reservation frontier before
+/// calling this, and pass its restored offset counter as `current_offset`.
+/// These values bound which saved consumer positions are plausible.
+///
+/// Missing directories produce empty maps. Valid records seed the visible
+/// offsets and their persistence state. Unreadable records and invalid records
+/// whose removal cannot be made durable retain their admission capacity slots.
+/// Offsets beyond the space reserved by the partition are clamped to
+/// `current_offset`, as during server boot. Clamping changes the visible
position
+/// without rewriting its file; persistence tracking retains the original value
+/// read from storage.
+///
+/// # Errors
+/// Returns [`ServerError::ConsumerOffsetsLoad`] if an existing offset
directory
+/// cannot be enumerated. Consumer recovery may already have seeded the
partition
+/// when group recovery fails, so callers must discard a failed recovery.
+#[allow(clippy::too_many_lines)]
+pub async fn configure_consumer_offsets_with_storage<S: DurableStorage>(
+ storage: &S,
+ partition: &mut IggyPartition<Rc<IggyMessageBus>>,
+ config: &ServerConfig,
+ namespace: IggyNamespace,
+ current_offset: u64,
) -> Result<(), ServerError> {
let stream_id = namespace.stream_id();
let topic_id = namespace.topic_id();
@@ -179,6 +214,7 @@ pub async fn configure_consumer_offsets(
let offset_space_ceiling =
current_offset.max(partition.mint_frontier().saturating_sub(1));
let recovered_consumers = load_partition_consumer_offsets(
+ storage,
&consumer_offsets_path,
"consumer",
stream_id,
@@ -221,6 +257,7 @@ pub async fn configure_consumer_offsets(
}
let recovered_groups = load_partition_consumer_group_offsets(
+ storage,
&consumer_group_offsets_path,
stream_id,
topic_id,
@@ -249,7 +286,7 @@ pub async fn configure_consumer_offsets(
let committed_offset = offset.offset.load(Ordering::Relaxed);
partition.seed_recovered_consumer_offset(
ConsumerKind::ConsumerGroup,
- u32::try_from(group_id.0).expect("recovered group id
originated as u32"),
+ offset.consumer_id,
committed_offset,
recovered_offset,
);
@@ -295,35 +332,45 @@ pub async fn configure_consumer_offsets(
Ok(())
}
-async fn load_partition_consumer_offsets(
+async fn load_partition_consumer_offsets<S: DurableStorage>(
+ storage: &S,
path: &str,
consumer_kind: &'static str,
stream_id: usize,
topic_id: usize,
partition_id: usize,
) -> Result<RecoveredOffsets<iggy_common::ConsumerOffset>, ServerError> {
- if !Path::new(path).exists() {
+ if !storage
+ .exists_following_links(Path::new(path))
+ .await
+ .unwrap_or(false)
+ {
return Ok(RecoveredOffsets::default());
}
- load_consumer_offsets(path).await.or_else(|source| {
- if matches!(&source,
IggyError::CannotReadConsumerOffsets(missing_path) if
!Path::new(missing_path).exists())
+ match load_consumer_offsets_with_storage(storage, path).await {
+ Ok(offsets) => Ok(offsets),
+ Err(IggyError::CannotReadConsumerOffsets(_))
+ if !storage
+ .exists_following_links(Path::new(path))
+ .await
+ .unwrap_or(false) =>
{
- return Ok(RecoveredOffsets::default());
+ Ok(RecoveredOffsets::default())
}
-
- Err(ServerError::ConsumerOffsetsLoad {
+ Err(source) => Err(ServerError::ConsumerOffsetsLoad {
consumer_kind,
stream_id,
topic_id,
partition_id,
path: path.to_string(),
source: Box::new(source),
- })
- })
+ }),
+ }
}
-async fn load_partition_consumer_group_offsets(
+async fn load_partition_consumer_group_offsets<S: DurableStorage>(
+ storage: &S,
path: &str,
stream_id: usize,
topic_id: usize,
@@ -332,25 +379,33 @@ async fn load_partition_consumer_group_offsets(
RecoveredOffsets<(iggy_common::ConsumerGroupId,
iggy_common::ConsumerOffset)>,
ServerError,
> {
- if !Path::new(path).exists() {
+ if !storage
+ .exists_following_links(Path::new(path))
+ .await
+ .unwrap_or(false)
+ {
return Ok(RecoveredOffsets::default());
}
- load_consumer_group_offsets(path).await.or_else(|source| {
- if matches!(&source,
IggyError::CannotReadConsumerOffsets(missing_path) if
!Path::new(missing_path).exists())
+ match load_consumer_group_offsets_with_storage(storage, path).await {
+ Ok(offsets) => Ok(offsets),
+ Err(IggyError::CannotReadConsumerOffsets(_))
+ if !storage
+ .exists_following_links(Path::new(path))
+ .await
+ .unwrap_or(false) =>
{
- return Ok(RecoveredOffsets::default());
+ Ok(RecoveredOffsets::default())
}
-
- Err(ServerError::ConsumerOffsetsLoad {
+ Err(source) => Err(ServerError::ConsumerOffsetsLoad {
consumer_kind: "consumer group",
stream_id,
topic_id,
partition_id,
path: path.to_string(),
source: Box::new(source),
- })
- })
+ }),
+ }
}
/// Provision an initial segment + writers for a partition that has none.
diff --git a/core/simulator/src/storage.rs b/core/simulator/src/storage.rs
index e9a47a8d8..441134971 100644
--- a/core/simulator/src/storage.rs
+++ b/core/simulator/src/storage.rs
@@ -277,7 +277,10 @@ impl DurableStorage for SimStorage {
}
async fn open(&self, path: &Path, mode: OpenMode) -> io::Result<SimFile> {
- let creates = matches!(mode, OpenMode::Create |
OpenMode::CreateOrOpen);
+ let creates = matches!(
+ mode,
+ OpenMode::Create | OpenMode::CreateWriteOnly |
OpenMode::CreateOrOpen
+ );
let operation = if creates {
StorageOperation::Create
} else {
@@ -290,7 +293,7 @@ impl DurableStorage for SimStorage {
if let Some(&inode) = state.directory(parent)?.get(&name) {
match &mut state.inodes[inode] {
Inode::File { buffered, .. } => {
- if mode == OpenMode::Create {
+ if matches!(mode, OpenMode::Create |
OpenMode::CreateWriteOnly) {
buffered.clear();
}
}
@@ -669,3 +672,6 @@ fn missing() -> io::Error {
#[cfg(test)]
mod tests;
+
+#[cfg(test)]
+mod purge;
diff --git a/core/simulator/src/storage/purge.rs
b/core/simulator/src/storage/purge.rs
new file mode 100644
index 000000000..adb97dc60
--- /dev/null
+++ b/core/simulator/src/storage/purge.rs
@@ -0,0 +1,469 @@
+// 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.
+
+//! Exercise purge completion and consumer recovery across simulated power
loss.
+//!
+//! A consumer offset is a bookmark: storing 2 means `Next` starts at message
3.
+//! Purge must remove those bookmarks as well as the old messages, so consumers
+//! can read a replacement history from offset 0. The purge generation marker
+//! records which purge was applied; it is separate from consumer progress.
+//!
+//! The two controls make that distinction observable. Without a purge,
recovery
+//! must preserve bookmark 2 and return messages 3 and 4. After a completed
purge,
+//! recovery must find no bookmarks and return all five messages, 0 through 4.
+//! Both controls cover individual consumers and groups under both offset
policies.
+//!
+//! The harness enters after message history has been reset. It uses production
+//! purge completion and consumer recovery with `SimStorage`, then polls
through
+//! the real `Next` path. Message recovery is narrower than server boot: a
helper
+//! replays a durable journal into a new partition. No partition memory
survives
+//! recovery. These controls do not inject sync failures or exercise purge
retries.
+
+use super::tests::owned_prepare;
+use super::{Crash, SimStorage};
+use configs::server::ServerConfig;
+use consensus::{LocalPipeline, Sequencer, VsrConsensus};
+use futures::executor::block_on;
+use iggy_common::{
+ ConsumerKind, Durability, IggyByteSize, PartitionStats, PollingStrategy,
TopicRuntimeOptions,
+};
+use journal::durable_storage::{DurableFile, DurableStorage, OpenMode};
+use journal::{DurableAppend, PartitionPrepareJournal};
+use message_bus::IggyMessageBus;
+use partitions::offset_storage::{
+ persist_offset_with_storage, persist_purge_generation_with_storage,
+};
+use partitions::{
+ IggyPartition, IggyPartitions, Partition, PartitionPathLayout,
PartitionsConfig, PollingArgs,
+ PollingConsumer,
+};
+use server::configure_consumer_offsets_with_storage;
+use server_common::send_messages::decode_batch_slice;
+use server_common::sharding::{IggyNamespace, ShardId};
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::sync::Arc;
+
+const CREATED_REVISION: u64 = 7;
+const OLD_GENERATION: u64 = 4;
+const NEW_GENERATION: u64 = 5;
+const STORED_OFFSET: u64 = 2;
+const CONSUMER_ID: usize = 7;
+const GROUP_ID: usize = 9;
+const STRAY_ID: usize = 99;
+const FRESH_MESSAGE_COUNT: u64 = 5;
+
+type TestPartition = IggyPartition<Rc<IggyMessageBus>>;
+
+/// Own the simulated filesystem and configuration, but no live partition
state.
+///
+/// Offset files and the purge marker use the server's directory layout. The
+/// separate message journal supplies history for rebuilding each new
partition.
+struct PurgeStorageHarness {
+ storage: SimStorage,
+ config: ServerConfig,
+ namespace: IggyNamespace,
+ /// Policy for consumer offsets; message durability is established by the
fixture.
+ policy: Durability,
+}
+
+impl PurgeStorageHarness {
+ /// Store bookmark 2 for a consumer and a group, plus the earlier purge
marker.
+ /// All files and their directory entries are durable before the test
starts,
+ /// including when the selected offset policy does not require an
immediate sync.
+ async fn with_stored_progress(policy: Durability) -> Self {
+ let harness = Self {
+ storage: SimStorage::default(),
+ config: ServerConfig {
+ path: "/purge".to_owned(),
+ ..ServerConfig::default()
+ },
+ namespace: IggyNamespace::new(0, 0, 42),
+ policy,
+ };
+ for kind in [ConsumerKind::Consumer, ConsumerKind::ConsumerGroup] {
+ let directory = harness.offset_directory(kind);
+ harness
+ .storage
+ .create_directories(&directory)
+ .await
+ .unwrap();
+ for ancestor in directory.ancestors() {
+ harness.storage.sync_directory(ancestor).await.unwrap();
+ }
+ }
+ harness
+ .persist_bookmark(ConsumerKind::Consumer, CONSUMER_ID)
+ .await;
+ harness
+ .persist_bookmark(ConsumerKind::ConsumerGroup, GROUP_ID)
+ .await;
+ persist_purge_generation_with_storage(
+ &harness.storage,
+ &format!("{}/purge.gen", harness.partition_directory()),
+ OLD_GENERATION,
+ CREATED_REVISION,
+ )
+ .await
+ .unwrap();
+ harness
+ }
+
+ /// Persist bookmark 2 without adding it to any partition's live offset
maps.
+ /// This also creates stray files that the purge must discover by scanning.
+ async fn persist_bookmark(&self, kind: ConsumerKind, consumer_id: usize) {
+ let directory = self.offset_directory(kind);
+ let path = directory.join(consumer_id.to_string());
+ persist_offset_with_storage(
+ &self.storage,
+ path.to_str().unwrap(),
+ STORED_OFFSET,
+ self.policy.is_persisted(),
+ )
+ .await
+ .unwrap();
+ // Establish the same durable input under both policies. The replicated
+ // policy does not itself require the cursor write to sync immediately.
+ self.storage
+ .open(&path, OpenMode::Read)
+ .await
+ .unwrap()
+ .sync()
+ .await
+ .unwrap();
+ self.storage.sync_directory(&directory).await.unwrap();
+ }
+
+ /// Journal five messages at offsets 0 through 4 without touching offset
directories.
+ /// Syncing the message journal must not accidentally make bookmark
cleanup durable.
+ async fn persist_fresh_history(&self) {
+ let mut journal = PartitionPrepareJournal::open_with_storage(
+ &self.journal_directory(),
+ self.namespace.inner(),
+ CREATED_REVISION,
+ self.storage.clone(),
+ )
+ .await
+ .unwrap();
+ let mut parent_checksum = 0;
+ for offset in 0..FRESH_MESSAGE_COUNT {
+ let operation_number = offset + 1;
+ // The helper fills the payload with the operation number, allowing
+ // polls to verify message contents as well as their offsets.
+ let prepare = owned_prepare(operation_number, parent_checksum,
offset);
+ parent_checksum = prepare.header().checksum;
+ journal.append(prepare.into_frozen()).await.unwrap();
+ }
+ journal.sync().await.unwrap();
+ // Never write back the whole simulated filesystem here: that would
also
+ // make an offset deletion durable after its own directory sync failed.
+ self.storage
+ .sync_directory(Path::new(&self.partition_directory()))
+ .await
+ .unwrap();
+ }
+
+ /// Rebuild messages, the applied purge marker, and consumer progress from
storage.
+ ///
+ /// Journal entries are appended and committed into a fresh in-memory
partition
+ /// before the shared server offset loader runs. This supplies real
readable
+ /// messages for `Next` without claiming to exercise the full server boot
path.
+ async fn recover_partition(&self) -> TestPartition {
+ let journal = PartitionPrepareJournal::open_with_storage(
+ &self.journal_directory(),
+ self.namespace.inner(),
+ CREATED_REVISION,
+ self.storage.clone(),
+ )
+ .await
+ .unwrap();
+ let mut partition = self.empty_partition();
+ for prepare in journal.prepares().await.unwrap() {
+ let operation_number = prepare.header().op;
+ partition.append_messages(prepare).await.unwrap();
+ partition
+ .consensus()
+ .sequencer()
+ .set_sequence(operation_number);
+ partition.consensus().advance_commit_max(operation_number);
+ partition.commit_journal(&partition_config()).await;
+ assert!(partition.fatal().is_none());
+ }
+ let current_offset = partition.offsets().commit_offset;
+ assert_eq!(current_offset, FRESH_MESSAGE_COUNT - 1);
+ self.recover_progress(&mut partition, current_offset).await;
+ partition
+ }
+
+ /// Use the shared marker and offset loaders, including the server's
offset clamping.
+ /// `current_offset` is the message bound against which saved progress is
checked.
+ async fn recover_progress(&self, partition: &mut TestPartition,
current_offset: u64) {
+ partition
+ .hydrate_applied_purge_generation_with_storage(&self.storage)
+ .await
+ .unwrap();
+ configure_consumer_offsets_with_storage(
+ &self.storage,
+ partition,
+ &self.config,
+ self.namespace,
+ current_offset,
+ )
+ .await
+ .unwrap();
+ }
+
+ /// Poll `Next` for the consumer and group, checking actual offsets and
payloads.
+ /// This executes and completes real polls with automatic offset commits
disabled;
+ /// it consumes the partition because completing polls can update live
tracking.
+ async fn poll_next_and_assert_messages(
+ &self,
+ partition: TestPartition,
+ expected_offsets: &[u64],
+ ) {
+ let partitions = IggyPartitions::new(ShardId::new(0),
partition_config());
+ partitions.insert(self.namespace, partition);
+ for consumer in consumers() {
+ let plan = partitions
+ .build_poll_snapshot(
+ &self.namespace,
+ consumer,
+ &PollingArgs {
+ strategy: PollingStrategy::next(),
+ count: 10,
+ auto_commit: false,
+ },
+ )
+ .unwrap();
+ assert!(!plan.needs_off_pump_io());
+ let completion = partitions
+ .complete_poll(&self.namespace, plan.execute().await)
+ .unwrap();
+ assert!(completion.replication.is_none());
+ let actual_offsets: Vec<_> = completion
+ .fragments
+ .iter()
+ .map(|fragment| {
+ let batch =
decode_batch_slice(fragment.as_slice()).unwrap();
+ assert_eq!(batch.message_count(), 1);
+ let message = batch.iter().next().unwrap();
+ let expected_byte = u8::try_from(batch.header.base_offset
+ 1).unwrap();
+ assert!(message.payload.iter().all(|byte| *byte ==
expected_byte));
+ batch.header.base_offset
+ })
+ .collect();
+ assert_eq!(
+ actual_offsets, expected_offsets,
+ "{:?}, {consumer:?}",
+ self.policy
+ );
+ }
+ }
+
+ /// Create a partition with no recovered messages or consumer progress.
+ /// Its identity matches the durable records so recovery can accept those
records.
+ fn empty_partition(&self) -> TestPartition {
+ let consensus = VsrConsensus::new(
+ 1,
+ 0,
+ 1,
+ self.namespace.inner(),
+ Rc::new(IggyMessageBus::new(0)),
+ LocalPipeline::new(),
+ );
+ consensus.init();
+ let mut partition = IggyPartition::with_in_memory_storage(
+ Arc::new(PartitionStats::default()),
+ consensus,
+ IggyByteSize::from(1024 * 1024),
+ );
+ partition.set_partition_dir(self.partition_directory());
+ partition.set_created_revision(CREATED_REVISION);
+ partition.set_runtime_options(TopicRuntimeOptions {
+ durability: Durability::Replicated,
+ consumer_offset_durability: self.policy,
+ ..TopicRuntimeOptions::default()
+ });
+ partition
+ }
+
+ fn partition_directory(&self) -> String {
+ self.config.get_partition_path(0, 0, 42)
+ }
+
+ fn journal_directory(&self) -> PathBuf {
+ Path::new(&self.partition_directory()).join("fresh-history")
+ }
+
+ fn offset_directory(&self, kind: ConsumerKind) -> PathBuf {
+ match kind {
+ ConsumerKind::Consumer => self.config.get_consumer_offsets_path(0,
0, 42).into(),
+ ConsumerKind::ConsumerGroup => {
+ self.config.get_consumer_group_offsets_path(0, 0, 42).into()
+ }
+ }
+ }
+}
+
+fn consumers() -> [PollingConsumer; 2] {
+ [
+ PollingConsumer::Consumer(CONSUMER_ID, 42),
+ PollingConsumer::ConsumerGroup(GROUP_ID, 1),
+ ]
+}
+
+fn partition_config() -> PartitionsConfig {
+ PartitionsConfig {
+ messages_required_to_save: 100,
+ size_of_messages_required_to_save: IggyByteSize::from(1024 * 1024),
+ validate_checksum: true,
+ segment_size: IggyByteSize::from(1024 * 1024),
+ preallocate_segments: false,
+ encryptor: None,
+ path_layout: PartitionPathLayout::default(),
+ }
+}
+
+/// After power loss, a new partition must load the consumer and group
+/// bookmarks from storage. Both saved bookmarks are 2, so Next must
+/// return messages 3 and 4.
+#[test]
+fn
given_stored_progress_when_power_is_lost_should_recover_both_consumer_bookmarks()
{
+ block_on(async {
+ for policy in [Durability::Replicated, Durability::Persisted] {
+ let harness =
PurgeStorageHarness::with_stored_progress(policy).await;
+ harness.persist_fresh_history().await;
+
+ harness.storage.crash(Crash::PowerLoss);
+ let recovered = harness.recover_partition().await;
+
+ assert_eq!(recovered.applied_purge_generation(), OLD_GENERATION);
+ for consumer in consumers() {
+ assert_eq!(recovered.get_consumer_offset(consumer),
Some(STORED_OFFSET));
+ }
+ // Bookmark 2 means the first three messages were already consumed.
+ harness
+ .poll_next_and_assert_messages(recovered, &[3, 4])
+ .await;
+ }
+ });
+}
+
+/// Purge must remove the consumer and group bookmarks before fresh messages
arrive.
+/// After power loss, a new partition must find no saved bookmarks, so Next
returns
+/// all fresh messages, 0 through 4. Restoring either old bookmark of 2 would
skip
+/// messages 0 through 2 even though that bookmark is still within the new
history.
+#[test]
+fn given_completed_purge_when_power_is_lost_should_read_all_fresh_messages() {
+ block_on(async {
+ for policy in [Durability::Replicated, Durability::Persisted] {
+ // Start at the completion phase: message history has been reset,
+ // but the old consumer and group bookmarks still need to be
cleared.
+ let harness =
PurgeStorageHarness::with_stored_progress(policy).await;
+ let mut partition = harness.empty_partition();
+ harness
+ .recover_progress(&mut partition, STORED_OFFSET)
+ .await;
+ assert_eq!(
+ partition.applied_purge_generation(),
+ OLD_GENERATION,
+ "{policy:?}: setup must load the earlier purge marker"
+ );
+ for consumer in consumers() {
+ assert_eq!(
+ partition.get_consumer_offset(consumer),
+ Some(STORED_OFFSET),
+ "{policy:?}, {consumer:?}: setup must load the old
bookmark"
+ );
+ }
+
+ // These files arrive after recovery, so only the production
directory
+ // sweep can discover them. Both directories must be cleaned
durably.
+ for kind in [ConsumerKind::Consumer, ConsumerKind::ConsumerGroup] {
+ harness.persist_bookmark(kind, STRAY_ID).await;
+ }
+
+ // Check the purge's effects on the live partition before
discarding it.
+ partition
+ .complete_purge_with_storage(&harness.storage, NEW_GENERATION)
+ .await
+ .expect("complete purge cleanup");
+ assert_eq!(
+ partition.applied_purge_generation(),
+ NEW_GENERATION,
+ "{policy:?}: purge must advance the applied generation"
+ );
+ for consumer in consumers() {
+ assert_eq!(
+ partition.get_consumer_offset(consumer),
+ None,
+ "{policy:?}, {consumer:?}: purge must clear the live
bookmark"
+ );
+ }
+ for kind in [ConsumerKind::Consumer, ConsumerKind::ConsumerGroup] {
+ assert_eq!(
+ partition.durable_consumer_offset_count(kind),
+ 0,
+ "{policy:?}, {kind:?}: purge must clear durability
tracking"
+ );
+ let directory = harness.offset_directory(kind);
+ let remaining_paths: Vec<_> = harness
+ .storage
+ .entries(&directory)
+ .await
+ .unwrap()
+ .into_iter()
+ .map(|entry| directory.join(entry.name))
+ .collect();
+ assert!(
+ remaining_paths.is_empty(),
+ "{policy:?}, {kind:?}: purge left bookmark files:
{remaining_paths:?}"
+ );
+ }
+ drop(partition);
+
+ // Save messages 0 through 4 after purge, then simulate power loss.
+ // The new partition must load its messages and bookmarks from
storage.
+ harness.persist_fresh_history().await;
+ harness.storage.crash(Crash::PowerLoss);
+ let recovered = harness.recover_partition().await;
+
+ assert_eq!(
+ recovered.applied_purge_generation(),
+ NEW_GENERATION,
+ "{policy:?}: the completed purge marker must survive power
loss"
+ );
+ for consumer in consumers() {
+ assert_eq!(
+ recovered.get_consumer_offset(consumer),
+ None,
+ "{policy:?}, {consumer:?}: a deleted bookmark must not
return after power loss"
+ );
+ }
+ for kind in [ConsumerKind::Consumer, ConsumerKind::ConsumerGroup] {
+ assert_eq!(
+ recovered.durable_consumer_offset_count(kind),
+ 0,
+ "{policy:?}, {kind:?}: recovery must find no durable
bookmarks"
+ );
+ }
+ harness
+ .poll_next_and_assert_messages(recovered, &[0, 1, 2, 3, 4])
+ .await;
+ }
+ });
+}
diff --git a/core/simulator/src/storage/tests.rs
b/core/simulator/src/storage/tests.rs
index 7e0eade54..13947f668 100644
--- a/core/simulator/src/storage/tests.rs
+++ b/core/simulator/src/storage/tests.rs
@@ -2235,7 +2235,7 @@ async fn assert_owned_segments(
);
}
-fn owned_prepare(op: u64, parent: u128, offset: u64) -> Message<PrepareHeader>
{
+pub(super) fn owned_prepare(op: u64, parent: u128, offset: u64) ->
Message<PrepareHeader> {
let payload = vec![
u8::try_from(op).unwrap();
OWNED_BATCH_BYTES - BATCH_HEADER_SIZE - BATCH_MESSAGE_HEADER_SIZE