use ahash::AHashMap as HashMap;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::Mutex;
use tracing::{debug, info};
use super::record::ConsumerRecord;
use super::{
AutoOffsetReset, Consumer, ConsumerBuilder, ConsumerRebalanceListener, IsolationLevel,
TopicPartition,
};
use crate::error::{KrafkaError, Result};
use crate::{Offset, PartitionId, Timestamp};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompactedEntry {
pub value: Bytes,
pub timestamp_ms: Timestamp,
pub offset: Offset,
pub partition: PartitionId,
}
impl CompactedEntry {
#[inline]
pub fn is_stale(&self, max_age: std::time::Duration) -> bool {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(i64::MAX);
now_ms.saturating_sub(self.timestamp_ms) > max_age.as_millis() as i64
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableChange {
pub key: Bytes,
pub old_value: Option<Bytes>,
pub new_value: Option<Bytes>,
pub partition: PartitionId,
pub offset: Offset,
pub timestamp: Timestamp,
}
impl TableChange {
#[inline]
pub fn is_delete(&self) -> bool {
self.new_value.is_none()
}
#[inline]
pub fn is_insert(&self) -> bool {
self.old_value.is_none() && self.new_value.is_some()
}
#[inline]
pub fn is_update(&self) -> bool {
self.old_value.is_some() && self.new_value.is_some()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompactedTableSnapshot {
pub entry_count: u64,
pub records_processed: u64,
pub tombstones_processed: u64,
pub caught_up: bool,
}
trait PartitionRewinder: Send + Sync {
fn rewind_to_beginning<'a>(
&'a self,
topic: &'a str,
partition: PartitionId,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>;
}
impl PartitionRewinder for Consumer {
fn rewind_to_beginning<'a>(
&'a self,
topic: &'a str,
partition: PartitionId,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(self.seek_to_beginning(topic, partition))
}
}
#[derive(Clone)]
pub struct CompactedTableClearListener {
table: Arc<Mutex<CompactedTable>>,
rewinder: Arc<std::sync::OnceLock<Arc<dyn PartitionRewinder>>>,
}
impl fmt::Debug for CompactedTableClearListener {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CompactedTableClearListener")
.field("consumer_attached", &self.rewinder.get().is_some())
.finish()
}
}
impl CompactedTableClearListener {
pub fn new(table: Arc<Mutex<CompactedTable>>) -> Self {
Self {
table,
rewinder: Arc::new(std::sync::OnceLock::new()),
}
}
pub fn attach_consumer(&self, consumer: Arc<Consumer>) {
if self.rewinder.set(consumer).is_err() {
tracing::warn!(
"CompactedTableClearListener: consumer already attached; ignoring repeat call"
);
}
}
#[cfg(test)]
fn with_rewinder(
table: Arc<Mutex<CompactedTable>>,
rewinder: Arc<dyn PartitionRewinder>,
) -> Self {
let cell = std::sync::OnceLock::new();
let _ = cell.set(rewinder);
Self {
table,
rewinder: Arc::new(cell),
}
}
async fn prune(&self, partitions: &[TopicPartition], reason: &str) {
if partitions.is_empty() {
return;
}
let ids: Vec<PartitionId> = partitions.iter().map(|tp| tp.partition).collect();
let removed = self.table.lock().await.remove_partitions(&ids);
debug!(
partitions = ?ids,
removed,
reason,
"CompactedTableClearListener pruned table entries"
);
}
}
impl ConsumerRebalanceListener for CompactedTableClearListener {
async fn on_partitions_assigned(&self, partitions: &[TopicPartition]) {
if partitions.is_empty() {
return;
}
self.prune(partitions, "assigned").await;
let Some(rewinder) = self.rewinder.get() else {
tracing::warn!(
partitions = partitions.len(),
"CompactedTableClearListener: no consumer attached, cannot rewind newly \
assigned partitions; the table will only observe keys written after this \
rebalance. Call attach_consumer() after building the consumer."
);
return;
};
for tp in partitions {
match rewinder.rewind_to_beginning(&tp.topic, tp.partition).await {
Ok(()) => debug!(
topic = %tp.topic,
partition = tp.partition,
"CompactedTableClearListener rewound newly assigned partition"
),
Err(e) => tracing::warn!(
topic = %tp.topic,
partition = tp.partition,
error = %e,
"CompactedTableClearListener failed to rewind newly assigned partition; \
the table may be missing keys for it"
),
}
}
}
async fn on_partitions_revoked(&self, partitions: &[TopicPartition]) {
self.prune(partitions, "revoked").await;
}
async fn on_partitions_lost(&self, partitions: &[TopicPartition]) {
self.prune(partitions, "lost").await;
}
}
#[derive(Default, Clone)]
pub struct CompactedTable {
entries: HashMap<Bytes, CompactedEntry>,
records_processed: u64,
tombstones_processed: u64,
}
impl CompactedTable {
pub fn new() -> Self {
Self::default()
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
entries: HashMap::with_capacity(capacity),
records_processed: 0,
tombstones_processed: 0,
}
}
#[must_use = "use ingest() if changes are not needed"]
pub fn apply(&mut self, records: &[ConsumerRecord]) -> Vec<TableChange> {
let mut changes = Vec::new();
for record in records {
self.records_processed += 1;
let Some(ref key) = record.key else {
continue;
};
let change = self.apply_keyed_record(key, record);
changes.push(change);
}
changes
}
pub fn get(&self, key: &[u8]) -> Option<&CompactedEntry> {
self.entries.get(key)
}
pub fn get_value(&self, key: &[u8]) -> Option<&Bytes> {
self.entries.get(key).map(|e| &e.value)
}
pub fn contains_key(&self, key: &[u8]) -> bool {
self.entries.contains_key(key)
}
#[inline]
pub fn len(&self) -> usize {
self.entries.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&Bytes, &CompactedEntry)> {
self.entries.iter()
}
pub fn keys(&self) -> impl Iterator<Item = &Bytes> {
self.entries.keys()
}
pub fn values(&self) -> impl Iterator<Item = &CompactedEntry> {
self.entries.values()
}
#[must_use]
pub fn snapshot(&self) -> HashMap<Bytes, CompactedEntry> {
self.entries.clone()
}
pub fn records_processed(&self) -> u64 {
self.records_processed
}
pub fn tombstones_processed(&self) -> u64 {
self.tombstones_processed
}
#[must_use]
pub fn metrics_snapshot(&self) -> CompactedTableSnapshot {
CompactedTableSnapshot {
entry_count: self.entries.len() as u64,
records_processed: self.records_processed,
tombstones_processed: self.tombstones_processed,
caught_up: false,
}
}
pub fn ingest(&mut self, records: &[ConsumerRecord]) {
for record in records {
self.records_processed += 1;
let Some(ref key) = record.key else {
continue;
};
self.ingest_keyed_record(key, record);
}
}
fn apply_keyed_record(&mut self, key: &Bytes, record: &ConsumerRecord) -> TableChange {
if record.is_tombstone() {
self.tombstones_processed += 1;
let old_entry = self.entries.remove(key.as_ref());
TableChange {
key: key.clone(),
old_value: old_entry.map(|e| e.value),
new_value: None,
partition: record.partition,
offset: record.offset,
timestamp: record.timestamp,
}
} else {
let Some(value) = record.value.clone() else {
unreachable!("non-tombstone compacted record must have a value");
};
let key_owned = key.clone();
let new_entry = CompactedEntry {
value: value.clone(),
timestamp_ms: record.timestamp,
offset: record.offset,
partition: record.partition,
};
if let Some(existing) = self.entries.get(key_owned.as_ref())
&& existing.partition != record.partition
{
tracing::warn!(
existing_partition = existing.partition,
new_partition = record.partition,
"CompactedTable: key appears in multiple partitions; \
entries will be conflated with last-write-wins semantics. \
If partition-scoped dedup is required, encode the partition \
into the key before ingesting records."
);
}
let old_entry = self.entries.insert(key_owned.clone(), new_entry);
TableChange {
key: key_owned,
old_value: old_entry.map(|e| e.value),
new_value: Some(value),
partition: record.partition,
offset: record.offset,
timestamp: record.timestamp,
}
}
}
fn ingest_keyed_record(&mut self, key: &Bytes, record: &ConsumerRecord) {
if record.is_tombstone() {
self.tombstones_processed += 1;
self.entries.remove(key.as_ref());
} else {
let Some(value) = record.value.clone() else {
unreachable!("non-tombstone compacted record must have a value");
};
if let Some(existing) = self.entries.get(key.as_ref())
&& existing.partition != record.partition
{
tracing::warn!(
existing_partition = existing.partition,
new_partition = record.partition,
"CompactedTable: key appears in multiple partitions; \
entries will be conflated with last-write-wins semantics. \
If partition-scoped dedup is required, encode the partition \
into the key before ingesting records."
);
}
self.entries.insert(
key.clone(),
CompactedEntry {
value,
timestamp_ms: record.timestamp,
offset: record.offset,
partition: record.partition,
},
);
}
}
pub fn clear(&mut self) {
self.entries.clear();
self.records_processed = 0;
self.tombstones_processed = 0;
}
pub fn remove_partitions(&mut self, partitions: &[PartitionId]) -> usize {
if partitions.is_empty() {
return 0;
}
let before = self.entries.len();
self.entries
.retain(|_, entry| !partitions.contains(&entry.partition));
before - self.entries.len()
}
}
impl<'a> IntoIterator for &'a CompactedTable {
type Item = (&'a Bytes, &'a CompactedEntry);
type IntoIter = std::collections::hash_map::Iter<'a, Bytes, CompactedEntry>;
fn into_iter(self) -> Self::IntoIter {
self.entries.iter()
}
}
impl IntoIterator for CompactedTable {
type Item = (Bytes, CompactedEntry);
type IntoIter = std::collections::hash_map::IntoIter<Bytes, CompactedEntry>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
impl fmt::Debug for CompactedTable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CompactedTable")
.field("len", &self.entries.len())
.field("records_processed", &self.records_processed)
.field("tombstones_processed", &self.tombstones_processed)
.finish()
}
}
impl PartialEq for CompactedTable {
fn eq(&self, other: &Self) -> bool {
self.entries == other.entries
}
}
impl Eq for CompactedTable {}
const DEFAULT_SCAN_TIMEOUT: Duration = Duration::from_secs(300);
trait ScanSource: Sync {
fn poll_records(
&self,
timeout: Duration,
) -> impl std::future::Future<Output = Result<Vec<ConsumerRecord>>> + Send;
fn assigned_partitions(
&self,
topic: &str,
) -> impl std::future::Future<Output = Vec<PartitionId>> + Send;
fn partition_position(
&self,
topic: &str,
partition: PartitionId,
) -> impl std::future::Future<Output = Option<Offset>> + Send;
fn end_offsets(
&self,
topic: &str,
) -> impl std::future::Future<Output = Result<HashMap<PartitionId, Result<Offset>>>> + Send;
}
impl ScanSource for Consumer {
async fn poll_records(&self, timeout: Duration) -> Result<Vec<ConsumerRecord>> {
self.poll(timeout).await
}
async fn assigned_partitions(&self, topic: &str) -> Vec<PartitionId> {
self.assignment()
.await
.get(topic)
.cloned()
.unwrap_or_default()
}
async fn partition_position(&self, topic: &str, partition: PartitionId) -> Option<Offset> {
self.position(topic, partition).await
}
async fn end_offsets(&self, topic: &str) -> Result<HashMap<PartitionId, Result<Offset>>> {
self.offsets_for_times_for_topic(topic, -1).await
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LaggingPartition {
partition: PartitionId,
position: Option<Offset>,
target: Offset,
}
impl fmt::Display for LaggingPartition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.position {
Some(pos) => write!(
f,
"partition {} (position {}, target {})",
self.partition, pos, self.target
),
None => write!(
f,
"partition {} (position unknown, target {})",
self.partition, self.target
),
}
}
}
fn scan_timeout_error(topic: &str, timeout: Duration, lagging: &[LaggingPartition]) -> KrafkaError {
let detail = lagging
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join(", ");
KrafkaError::timeout(format!(
"scan of compacted topic '{topic}' did not catch up within {timeout:?}; \
partitions still behind their target offset: [{detail}]"
))
}
async fn lagging_partitions<S: ScanSource>(
source: &S,
topic: &str,
targets: &HashMap<PartitionId, Offset>,
) -> Vec<LaggingPartition> {
let assigned = source.assigned_partitions(topic).await;
if assigned.is_empty() {
let mut all: Vec<LaggingPartition> = targets
.iter()
.map(|(&partition, &target)| LaggingPartition {
partition,
position: None,
target,
})
.collect();
all.sort_by_key(|l| l.partition);
return all;
}
let mut lagging = Vec::new();
for partition in assigned {
let Some(&target) = targets.get(&partition) else {
continue;
};
if target <= 0 {
continue;
}
let position = source.partition_position(topic, partition).await;
if position.is_none_or(|pos| pos < target) {
lagging.push(LaggingPartition {
partition,
position,
target,
});
}
}
lagging.sort_by_key(|l| l.partition);
lagging
}
async fn run_scan<S: ScanSource>(
source: &S,
topic: &str,
table: &mut CompactedTable,
poll_timeout: Duration,
timeout: Duration,
) -> Result<()> {
if source.assigned_partitions(topic).await.is_empty() {
return Err(KrafkaError::invalid_state(format!(
"no partitions assigned for topic '{topic}'; \
assign partitions before calling scan()"
)));
}
let hwm_results = source.end_offsets(topic).await?;
let mut scan_target_hwms: HashMap<PartitionId, Offset> =
HashMap::with_capacity(hwm_results.len());
for (partition, result) in hwm_results {
let offset = result.map_err(|e| {
KrafkaError::invalid_state(format!(
"failed to fetch high-watermark for '{topic}' partition {partition}: {e}"
))
})?;
scan_target_hwms.insert(partition, offset);
}
if scan_target_hwms.values().all(|&hwm| hwm <= 0) {
info!("Compacted topic '{topic}' has no data yet (all partition HWMs = 0); scan complete");
return Ok(());
}
scan_target_hwms.retain(|_, &mut hwm| hwm > 0);
info!(
topic = %topic,
partitions = scan_target_hwms.len(),
timeout = ?timeout,
"Starting compacted topic scan (HWM snapshot taken)"
);
let deadline = std::time::Instant::now() + timeout;
loop {
let mut records = source.poll_records(poll_timeout).await?;
let before_len = records.len();
records.retain(|r| r.topic == topic);
let filtered = before_len - records.len();
if filtered > 0 {
debug!("Filtered out {filtered} record(s) from other topics during scan for '{topic}'");
}
table.ingest(&records);
let lagging = lagging_partitions(source, topic, &scan_target_hwms).await;
if lagging.is_empty() {
info!(
"Compacted topic scan complete for '{}': {} keys, {} records processed, \
{} tombstones",
topic,
table.len(),
table.records_processed(),
table.tombstones_processed(),
);
return Ok(());
}
if std::time::Instant::now() >= deadline {
return Err(scan_timeout_error(topic, timeout, &lagging));
}
}
}
pub struct CompactedTopicConsumer {
consumer: Consumer,
topic: String,
table: CompactedTable,
caught_up: bool,
}
impl fmt::Debug for CompactedTopicConsumer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CompactedTopicConsumer")
.field("topic", &self.topic)
.field("caught_up", &self.caught_up)
.field("table", &self.table)
.finish()
}
}
impl CompactedTopicConsumer {
pub fn from_consumer(consumer: Consumer, topic: impl Into<String>) -> Self {
Self {
consumer,
topic: topic.into(),
table: CompactedTable::new(),
caught_up: false,
}
}
pub async fn scan(&mut self, poll_timeout: Duration) -> Result<()> {
self.scan_with_timeout(poll_timeout, DEFAULT_SCAN_TIMEOUT)
.await
}
pub async fn scan_with_timeout(
&mut self,
poll_timeout: Duration,
timeout: Duration,
) -> Result<()> {
run_scan(
&self.consumer,
&self.topic,
&mut self.table,
poll_timeout,
timeout,
)
.await?;
self.caught_up = true;
Ok(())
}
pub async fn poll(&mut self, timeout: Duration) -> Result<Vec<TableChange>> {
let mut records = self.consumer.poll(timeout).await?;
let before_len = records.len();
records.retain(|r| r.topic == self.topic);
let filtered = before_len - records.len();
if filtered > 0 {
debug!(
"Filtered out {} record(s) from other topics during poll for '{}'",
filtered, self.topic
);
}
let changes = self.table.apply(&records);
if !self.caught_up && self.check_caught_up().await {
self.caught_up = true;
debug!(
"CompactedTopicConsumer for '{}' caught up via poll()",
self.topic
);
}
Ok(changes)
}
pub fn table(&self) -> &CompactedTable {
&self.table
}
pub fn table_mut(&mut self) -> &mut CompactedTable {
&mut self.table
}
pub fn is_caught_up(&self) -> bool {
self.caught_up
}
#[must_use]
pub fn metrics_snapshot(&self) -> CompactedTableSnapshot {
let mut snap = self.table.metrics_snapshot();
snap.caught_up = self.caught_up;
snap
}
pub fn topic(&self) -> &str {
&self.topic
}
pub fn consumer(&self) -> &Consumer {
&self.consumer
}
pub fn consumer_mut(&mut self) -> &mut Consumer {
&mut self.consumer
}
pub fn into_parts(self) -> (Consumer, CompactedTable) {
(self.consumer, self.table)
}
pub async fn close(&self) -> Result<()> {
self.consumer.close().await
}
async fn check_caught_up(&self) -> bool {
let assignments = self.consumer.assignment().await;
let Some(partitions) = assignments.get(&self.topic) else {
return false;
};
for &partition in partitions {
let position = self.consumer.position(&self.topic, partition).await;
let high_watermark = self
.consumer
.cached_end_offset(&self.topic, partition)
.await;
match (position, high_watermark) {
(Some(pos), Some(hw)) if pos >= hw => continue,
(_, Some(0)) => continue,
_ => return false,
}
}
true
}
}
impl CompactedTopicConsumer {
pub async fn from_consumer_builder(
builder: ConsumerBuilder,
topic: impl Into<String>,
) -> Result<Self> {
let topic = topic.into();
let consumer = builder
.auto_offset_reset(AutoOffsetReset::Earliest)
.enable_auto_commit(false)
.isolation_level(IsolationLevel::ReadCommitted)
.build()
.await?;
consumer
.metadata
.refresh_for_topics(Some(&[&topic]))
.await?;
let partition_count = consumer.metadata.partition_count(&topic).ok_or_else(|| {
KrafkaError::config(format!("topic '{topic}' not found in cluster metadata"))
})?;
let partition_count = PartitionId::try_from(partition_count).map_err(|_| {
KrafkaError::config(format!(
"topic '{topic}' has too many partitions to fit in PartitionId"
))
})?;
let partitions: Vec<PartitionId> = (0..partition_count).collect();
consumer.assign(&topic, partitions).await?;
debug!(
"CompactedTopicConsumer initialized for '{}' with {} partitions",
topic, partition_count
);
Ok(CompactedTopicConsumer::from_consumer(consumer, topic))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn make_record(
key: Option<&str>,
value: Option<&str>,
partition: PartitionId,
offset: Offset,
) -> ConsumerRecord {
ConsumerRecord {
topic: "test-topic".to_string(),
partition,
offset,
timestamp: offset * 1000,
timestamp_type: 0,
key: key.map(|k| Bytes::from(k.to_string())),
value: value.map(|v| Bytes::from(v.to_string())),
headers: Vec::new(),
leader_epoch: None,
delivery_count: None,
}
}
#[test]
fn test_table_insert() {
let mut table = CompactedTable::new();
let records = vec![
make_record(Some("k1"), Some("v1"), 0, 0),
make_record(Some("k2"), Some("v2"), 0, 1),
];
let changes = table.apply(&records);
assert_eq!(table.len(), 2);
assert_eq!(table.get_value(b"k1"), Some(&Bytes::from("v1")));
assert_eq!(table.get_value(b"k2"), Some(&Bytes::from("v2")));
assert_eq!(changes.len(), 2);
assert!(changes[0].is_insert());
assert!(changes[1].is_insert());
assert_eq!(table.records_processed(), 2);
assert_eq!(table.tombstones_processed(), 0);
}
#[test]
fn test_table_update() {
let mut table = CompactedTable::new();
table.ingest(&[make_record(Some("k1"), Some("old"), 0, 0)]);
let changes = table.apply(&[make_record(Some("k1"), Some("new"), 0, 5)]);
assert_eq!(table.len(), 1);
assert_eq!(table.get_value(b"k1"), Some(&Bytes::from("new")));
assert_eq!(changes.len(), 1);
assert!(changes[0].is_update());
assert_eq!(changes[0].old_value, Some(Bytes::from("old")));
assert_eq!(changes[0].new_value, Some(Bytes::from("new")));
}
#[test]
fn test_table_tombstone() {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("k1"), Some("v1"), 0, 0),
make_record(Some("k2"), Some("v2"), 0, 1),
]);
let changes = table.apply(&[make_record(Some("k1"), None, 0, 10)]);
assert_eq!(table.len(), 1);
assert!(!table.contains_key(b"k1"));
assert_eq!(table.get_value(b"k2"), Some(&Bytes::from("v2")));
assert_eq!(changes.len(), 1);
assert!(changes[0].is_delete());
assert_eq!(changes[0].old_value, Some(Bytes::from("v1")));
assert_eq!(changes[0].new_value, None);
assert_eq!(table.tombstones_processed(), 1);
}
#[test]
fn test_table_tombstone_for_missing_key() {
let mut table = CompactedTable::new();
let changes = table.apply(&[make_record(Some("missing"), None, 0, 0)]);
assert!(table.is_empty());
assert_eq!(changes.len(), 1);
assert!(changes[0].is_delete());
assert_eq!(changes[0].old_value, None);
assert_eq!(table.tombstones_processed(), 1);
}
#[test]
fn test_table_skips_keyless() {
let mut table = CompactedTable::new();
let records = vec![
make_record(None, Some("value-without-key"), 0, 0),
make_record(Some("k1"), Some("v1"), 0, 1),
];
let changes = table.apply(&records);
assert_eq!(table.len(), 1);
assert_eq!(changes.len(), 1);
assert_eq!(table.records_processed(), 2);
}
#[test]
fn test_table_full_lifecycle() {
let mut table = CompactedTable::new();
let changes = table.apply(&[
make_record(Some("user-1"), Some("Alice"), 0, 0),
make_record(Some("user-2"), Some("Bob"), 0, 1),
]);
assert_eq!(table.len(), 2);
assert!(changes.iter().all(|c| c.is_insert()));
let changes = table.apply(&[make_record(Some("user-1"), Some("Alice V2"), 0, 2)]);
assert_eq!(table.get_value(b"user-1"), Some(&Bytes::from("Alice V2")));
assert!(changes[0].is_update());
let changes = table.apply(&[make_record(Some("user-2"), None, 0, 3)]);
assert_eq!(table.len(), 1);
assert!(changes[0].is_delete());
let changes = table.apply(&[make_record(Some("user-2"), Some("Bob V2"), 0, 4)]);
assert_eq!(table.len(), 2);
assert!(changes[0].is_insert());
}
#[test]
fn test_table_empty_input() {
let mut table = CompactedTable::new();
table.ingest(&[make_record(Some("k1"), Some("v1"), 0, 0)]);
let changes = table.apply(&[]);
assert_eq!(table.len(), 1);
assert!(changes.is_empty());
}
#[test]
fn test_table_multiple_partitions() {
let mut table = CompactedTable::new();
let records = vec![
make_record(Some("k1"), Some("v1"), 0, 0),
make_record(Some("k2"), Some("v2"), 1, 0),
make_record(Some("k1"), Some("v1-updated"), 0, 1),
];
let changes = table.apply(&records);
assert_eq!(table.len(), 2);
assert_eq!(table.get_value(b"k1"), Some(&Bytes::from("v1-updated")));
assert_eq!(changes.len(), 3);
assert!(changes[0].is_insert());
assert!(changes[1].is_insert());
assert!(changes[2].is_update());
assert_eq!(changes[0].partition, 0);
assert_eq!(changes[1].partition, 1);
}
#[test]
fn test_table_with_capacity() {
let table = CompactedTable::with_capacity(100);
assert!(table.is_empty());
assert_eq!(table.records_processed(), 0);
}
#[test]
fn test_table_iter() {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("a"), Some("1"), 0, 0),
make_record(Some("b"), Some("2"), 0, 1),
]);
let items: HashMap<&Bytes, &Bytes> = table.iter().map(|(k, e)| (k, &e.value)).collect();
assert_eq!(items.len(), 2);
assert_eq!(items[&Bytes::from("a")], &Bytes::from("1"));
assert_eq!(items[&Bytes::from("b")], &Bytes::from("2"));
}
#[test]
fn test_table_snapshot() {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("k1"), Some("v1"), 0, 0),
make_record(Some("k2"), Some("v2"), 0, 1),
]);
let snap = table.snapshot();
assert_eq!(snap.len(), 2);
assert_eq!(
snap.get(&Bytes::from("k1")).map(|e| &e.value),
Some(&Bytes::from("v1"))
);
}
#[test]
fn test_table_debug() {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("k1"), Some("v1"), 0, 0),
make_record(Some("k2"), None, 0, 1),
]);
let debug = format!("{table:?}");
assert!(debug.contains("len: 1"));
assert!(debug.contains("records_processed: 2"));
assert!(debug.contains("tombstones_processed: 1"));
}
#[test]
fn test_table_clear() {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("k1"), Some("v1"), 0, 0),
make_record(Some("k2"), None, 0, 1),
]);
assert_eq!(table.len(), 1);
assert_eq!(table.records_processed(), 2);
assert_eq!(table.tombstones_processed(), 1);
table.clear();
assert!(table.is_empty());
assert_eq!(table.records_processed(), 0);
assert_eq!(table.tombstones_processed(), 0);
}
#[test]
fn test_table_into_iterator() {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("a"), Some("1"), 0, 0),
make_record(Some("b"), Some("2"), 0, 1),
]);
let items: HashMap<&Bytes, &Bytes> =
(&table).into_iter().map(|(k, e)| (k, &e.value)).collect();
assert_eq!(items.len(), 2);
assert_eq!(items[&Bytes::from("a")], &Bytes::from("1"));
}
#[test]
fn test_table_change_classification() {
let insert = TableChange {
key: Bytes::from("k"),
old_value: None,
new_value: Some(Bytes::from("v")),
partition: 0,
offset: 0,
timestamp: 0,
};
assert!(insert.is_insert());
assert!(!insert.is_update());
assert!(!insert.is_delete());
let update = TableChange {
key: Bytes::from("k"),
old_value: Some(Bytes::from("old")),
new_value: Some(Bytes::from("new")),
partition: 0,
offset: 1,
timestamp: 1000,
};
assert!(!update.is_insert());
assert!(update.is_update());
assert!(!update.is_delete());
let delete = TableChange {
key: Bytes::from("k"),
old_value: Some(Bytes::from("v")),
new_value: None,
partition: 0,
offset: 2,
timestamp: 2000,
};
assert!(!delete.is_insert());
assert!(!delete.is_update());
assert!(delete.is_delete());
}
#[test]
fn test_table_keys() {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("a"), Some("1"), 0, 0),
make_record(Some("b"), Some("2"), 0, 1),
]);
let mut keys: Vec<&Bytes> = table.keys().collect();
keys.sort();
assert_eq!(keys, vec![&Bytes::from("a"), &Bytes::from("b")]);
}
#[test]
fn test_table_values() {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("a"), Some("1"), 0, 0),
make_record(Some("b"), Some("2"), 0, 1),
]);
let mut values: Vec<&Bytes> = table.values().map(|e| &e.value).collect();
values.sort();
assert_eq!(values, vec![&Bytes::from("1"), &Bytes::from("2")]);
}
#[test]
fn test_table_owned_into_iterator() {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("a"), Some("1"), 0, 0),
make_record(Some("b"), Some("2"), 0, 1),
]);
let items: HashMap<Bytes, Bytes> = table.into_iter().map(|(k, e)| (k, e.value)).collect();
assert_eq!(items.len(), 2);
assert_eq!(items.get(&Bytes::from("a")), Some(&Bytes::from("1")));
assert_eq!(items.get(&Bytes::from("b")), Some(&Bytes::from("2")));
}
#[test]
fn test_table_clone_preserves_state() {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("k1"), Some("v1"), 0, 0),
make_record(Some("k2"), Some("v2"), 0, 1),
make_record(Some("k3"), None, 0, 2), ]);
let cloned = table.clone();
assert_eq!(cloned.len(), table.len());
assert_eq!(cloned.get(b"k1"), table.get(b"k1"));
assert_eq!(cloned.get(b"k2"), table.get(b"k2"));
assert_eq!(cloned.records_processed(), table.records_processed());
assert_eq!(cloned.tombstones_processed(), table.tombstones_processed());
}
#[test]
fn test_table_ingest() {
let mut table = CompactedTable::new();
let records = vec![
make_record(Some("k1"), Some("v1"), 0, 0),
make_record(Some("k2"), Some("v2"), 0, 1),
make_record(None, Some("no-key"), 0, 2),
make_record(Some("k1"), None, 0, 3), ];
table.ingest(&records);
assert_eq!(table.len(), 1);
assert!(!table.contains_key(b"k1"));
assert_eq!(table.get_value(b"k2"), Some(&Bytes::from("v2")));
assert_eq!(table.records_processed(), 4);
assert_eq!(table.tombstones_processed(), 1);
}
#[test]
fn test_table_ingest_matches_apply_state() {
let records = vec![
make_record(Some("a"), Some("1"), 0, 0),
make_record(Some("b"), Some("2"), 1, 0),
make_record(Some("a"), Some("3"), 0, 1),
make_record(Some("b"), None, 1, 1),
];
let mut via_apply = CompactedTable::new();
let _ = via_apply.apply(&records);
let mut via_ingest = CompactedTable::new();
via_ingest.ingest(&records);
assert_eq!(via_apply, via_ingest);
assert_eq!(
via_apply.records_processed(),
via_ingest.records_processed()
);
assert_eq!(
via_apply.tombstones_processed(),
via_ingest.tombstones_processed()
);
}
#[test]
fn test_table_equality_ignores_counters() {
let mut t1 = CompactedTable::new();
t1.ingest(&[make_record(Some("k"), Some("v"), 0, 5)]);
let mut t2 = CompactedTable::new();
t2.ingest(&[
make_record(None, Some("noise"), 0, 0), make_record(Some("k"), Some("v"), 0, 5), ]);
assert_eq!(t1, t2);
assert_ne!(t1.records_processed(), t2.records_processed());
}
#[test]
fn test_table_same_key_lifecycle_in_single_batch() {
let mut table = CompactedTable::new();
let records = vec![
make_record(Some("x"), Some("v1"), 0, 0), make_record(Some("x"), Some("v2"), 0, 1), make_record(Some("x"), None, 0, 2), make_record(Some("x"), Some("v3"), 0, 3), ];
let changes = table.apply(&records);
assert_eq!(table.len(), 1);
assert_eq!(table.get_value(b"x"), Some(&Bytes::from("v3")));
assert_eq!(changes.len(), 4);
assert!(changes[0].is_insert());
assert_eq!(changes[0].old_value, None);
assert_eq!(changes[0].new_value, Some(Bytes::from("v1")));
assert!(changes[1].is_update());
assert_eq!(changes[1].old_value, Some(Bytes::from("v1")));
assert_eq!(changes[1].new_value, Some(Bytes::from("v2")));
assert!(changes[2].is_delete());
assert_eq!(changes[2].old_value, Some(Bytes::from("v2")));
assert!(changes[3].is_insert());
assert_eq!(changes[3].old_value, None);
assert_eq!(changes[3].new_value, Some(Bytes::from("v3")));
assert_eq!(table.records_processed(), 4);
assert_eq!(table.tombstones_processed(), 1);
}
#[test]
fn test_all_public_types_are_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CompactedTable>();
assert_send_sync::<TableChange>();
assert_send_sync::<CompactedTopicConsumer>();
}
#[tokio::test]
async fn from_consumer_builder_inherits_consumer_validation() {
let result =
CompactedTopicConsumer::from_consumer_builder(Consumer::builder(), "test").await;
let err = result.expect_err("a builder with no brokers cannot build");
assert!(
err.to_string().contains("bootstrap_servers"),
"expected the consumer's own validation, got: {err}"
);
}
#[test]
fn test_remove_partitions_removes_only_listed_partitions() {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("p0-a"), Some("v"), 0, 0),
make_record(Some("p1-a"), Some("v"), 1, 0),
make_record(Some("p1-b"), Some("v"), 1, 1),
make_record(Some("p2-a"), Some("v"), 2, 0),
]);
let removed = table.remove_partitions(&[1]);
assert_eq!(removed, 2);
assert_eq!(table.len(), 2);
assert!(table.contains_key(b"p0-a"));
assert!(table.contains_key(b"p2-a"));
assert!(!table.contains_key(b"p1-a"));
assert!(!table.contains_key(b"p1-b"));
assert_eq!(table.records_processed(), 4);
}
#[test]
fn test_remove_partitions_empty_list_is_noop() {
let mut table = CompactedTable::new();
table.ingest(&[make_record(Some("k"), Some("v"), 0, 0)]);
assert_eq!(table.remove_partitions(&[]), 0);
assert_eq!(table.len(), 1);
}
#[derive(Default)]
struct RecordingRewinder {
calls: std::sync::Mutex<Vec<(String, PartitionId)>>,
}
impl RecordingRewinder {
fn calls(&self) -> Vec<(String, PartitionId)> {
self.calls.lock().unwrap().clone()
}
}
impl PartitionRewinder for RecordingRewinder {
fn rewind_to_beginning<'a>(
&'a self,
topic: &'a str,
partition: PartitionId,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
self.calls
.lock()
.unwrap()
.push((topic.to_string(), partition));
Box::pin(async { Ok(()) })
}
}
fn populated_table() -> Arc<Mutex<CompactedTable>> {
let mut table = CompactedTable::new();
table.ingest(&[
make_record(Some("p0"), Some("v"), 0, 0),
make_record(Some("p1"), Some("v"), 1, 0),
make_record(Some("p2"), Some("v"), 2, 0),
]);
Arc::new(Mutex::new(table))
}
#[tokio::test]
async fn test_listener_revocation_prunes_only_revoked_partitions() {
let table = populated_table();
let listener = CompactedTableClearListener::new(Arc::clone(&table));
listener
.on_partitions_revoked(&[TopicPartition::new("test-topic", 1)])
.await;
let t = table.lock().await;
assert_eq!(t.len(), 2, "retained partitions must survive revocation");
assert!(t.contains_key(b"p0"));
assert!(t.contains_key(b"p2"));
assert!(!t.contains_key(b"p1"));
}
#[tokio::test]
async fn test_listener_loss_prunes_only_lost_partitions() {
let table = populated_table();
let listener = CompactedTableClearListener::new(Arc::clone(&table));
listener
.on_partitions_lost(&[TopicPartition::new("test-topic", 2)])
.await;
let t = table.lock().await;
assert_eq!(t.len(), 2);
assert!(t.contains_key(b"p0"));
assert!(t.contains_key(b"p1"));
assert!(!t.contains_key(b"p2"));
}
#[tokio::test]
async fn test_listener_assignment_rewinds_new_partitions() {
let table = populated_table();
let rewinder = Arc::new(RecordingRewinder::default());
let listener = CompactedTableClearListener::with_rewinder(
Arc::clone(&table),
Arc::clone(&rewinder) as Arc<dyn PartitionRewinder>,
);
listener
.on_partitions_assigned(&[
TopicPartition::new("test-topic", 1),
TopicPartition::new("test-topic", 3),
])
.await;
assert_eq!(
rewinder.calls(),
vec![("test-topic".to_string(), 1), ("test-topic".to_string(), 3)],
"newly assigned partitions must be replayed from the start"
);
let t = table.lock().await;
assert!(!t.contains_key(b"p1"));
assert!(t.contains_key(b"p0"));
assert!(t.contains_key(b"p2"));
}
#[tokio::test]
async fn test_listener_assignment_without_consumer_does_not_panic() {
let table = populated_table();
let listener = CompactedTableClearListener::new(Arc::clone(&table));
listener
.on_partitions_assigned(&[TopicPartition::new("test-topic", 0)])
.await;
let t = table.lock().await;
assert!(!t.contains_key(b"p0"));
assert_eq!(t.len(), 2);
}
#[tokio::test]
async fn test_listener_empty_rebalance_is_noop() {
let table = populated_table();
let listener = CompactedTableClearListener::new(Arc::clone(&table));
listener.on_partitions_revoked(&[]).await;
listener.on_partitions_assigned(&[]).await;
assert_eq!(table.lock().await.len(), 3);
}
struct FakeScanSource {
assigned: Vec<PartitionId>,
end_offsets: HashMap<PartitionId, Offset>,
positions: std::sync::Mutex<HashMap<PartitionId, Offset>>,
advance: HashMap<PartitionId, Offset>,
first_poll_records: std::sync::Mutex<Vec<ConsumerRecord>>,
}
impl FakeScanSource {
fn new(
assigned: &[PartitionId],
end_offsets: &[(PartitionId, Offset)],
positions: &[(PartitionId, Offset)],
advance: &[(PartitionId, Offset)],
) -> Self {
Self {
assigned: assigned.to_vec(),
end_offsets: end_offsets.iter().copied().collect(),
positions: std::sync::Mutex::new(positions.iter().copied().collect()),
advance: advance.iter().copied().collect(),
first_poll_records: std::sync::Mutex::new(Vec::new()),
}
}
fn with_records(self, records: Vec<ConsumerRecord>) -> Self {
*self.first_poll_records.lock().unwrap() = records;
self
}
}
impl ScanSource for FakeScanSource {
async fn poll_records(&self, _timeout: Duration) -> Result<Vec<ConsumerRecord>> {
let mut positions = self.positions.lock().unwrap();
for (partition, step) in &self.advance {
*positions.entry(*partition).or_insert(0) += *step;
}
drop(positions);
Ok(std::mem::take(
&mut *self.first_poll_records.lock().unwrap(),
))
}
async fn assigned_partitions(&self, _topic: &str) -> Vec<PartitionId> {
self.assigned.clone()
}
async fn partition_position(&self, _topic: &str, partition: PartitionId) -> Option<Offset> {
self.positions.lock().unwrap().get(&partition).copied()
}
async fn end_offsets(&self, _topic: &str) -> Result<HashMap<PartitionId, Result<Offset>>> {
Ok(self.end_offsets.iter().map(|(&p, &o)| (p, Ok(o))).collect())
}
}
#[tokio::test]
async fn test_scan_times_out_and_names_lagging_partitions() {
let source = FakeScanSource::new(
&[0, 1, 2],
&[(0, 10), (1, 5), (2, 7)],
&[(0, 10), (1, 2)],
&[],
);
let mut table = CompactedTable::new();
let err = run_scan(
&source,
"test-topic",
&mut table,
Duration::from_millis(1),
Duration::from_millis(30),
)
.await
.expect_err("scan must not run forever when a partition never converges");
let msg = err.to_string();
assert!(
msg.contains("partition 1 (position 2, target 5)"),
"error must name the stalled partition and its lag: {msg}"
);
assert!(
msg.contains("partition 2 (position unknown, target 7)"),
"error must name partitions with no known position: {msg}"
);
assert!(
!msg.contains("partition 0"),
"caught-up partitions must not be reported as lagging: {msg}"
);
}
#[tokio::test]
async fn test_scan_completes_when_partitions_reach_targets() {
let source = FakeScanSource::new(&[0], &[(0, 3)], &[(0, 0)], &[(0, 3)])
.with_records(vec![make_record(Some("k1"), Some("v1"), 0, 0)]);
let mut table = CompactedTable::new();
run_scan(
&source,
"test-topic",
&mut table,
Duration::from_millis(1),
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(table.get_value(b"k1"), Some(&Bytes::from("v1")));
}
#[tokio::test]
async fn test_scan_requires_an_assignment() {
let source = FakeScanSource::new(&[], &[], &[], &[]);
let mut table = CompactedTable::new();
let err = run_scan(
&source,
"test-topic",
&mut table,
Duration::from_millis(1),
Duration::from_millis(10),
)
.await
.unwrap_err();
assert!(err.to_string().contains("no partitions assigned"));
}
#[tokio::test]
async fn test_scan_returns_early_for_empty_topic() {
let source = FakeScanSource::new(&[0], &[(0, 0)], &[], &[]);
let mut table = CompactedTable::new();
run_scan(
&source,
"test-topic",
&mut table,
Duration::from_millis(1),
Duration::from_millis(10),
)
.await
.unwrap();
assert!(table.is_empty());
}
#[tokio::test]
async fn test_lagging_partitions_ignores_empty_and_unknown_targets() {
let source = FakeScanSource::new(&[0, 9], &[], &[], &[]);
let targets: HashMap<PartitionId, Offset> = [(0, 0)].into_iter().collect();
assert!(
lagging_partitions(&source, "test-topic", &targets)
.await
.is_empty()
);
}
#[test]
fn test_scan_timeout_error_lists_every_lagging_partition() {
let err = scan_timeout_error(
"cfg",
Duration::from_secs(2),
&[
LaggingPartition {
partition: 3,
position: Some(7),
target: 42,
},
LaggingPartition {
partition: 4,
position: None,
target: 1,
},
],
);
let msg = err.to_string();
assert!(msg.contains("cfg"));
assert!(msg.contains("partition 3 (position 7, target 42)"));
assert!(msg.contains("partition 4 (position unknown, target 1)"));
}
}