use crate::checkpoint::{self, CheckpointBackend, CheckpointStore};
use crate::endpoints::file::{encode_record, parse_delimiter, parse_message};
use crate::models::{Compression, FileFormat, ObjectStoreConfig};
#[cfg(feature = "encryption")]
use crate::support::crypto::Crypto;
use crate::traits::{
BoxFuture, ConsumerError, MessageConsumer, MessageDisposition, MessagePublisher,
PublisherError, ReceivedBatch, SentBatch,
};
use crate::CanonicalMessage;
use anyhow::{anyhow, Context};
use async_trait::async_trait;
use futures::StreamExt;
use object_store::{path::Path as ObjPath, ObjectStore, ObjectStoreExt, PutPayload};
use std::any::Any;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tracing::{info, trace, warn};
fn build_store(url: &str) -> anyhow::Result<(Box<dyn ObjectStore>, ObjPath)> {
let parsed =
url::Url::parse(url).with_context(|| format!("Invalid object_store url '{url}'"))?;
let env = std::env::vars().map(|(k, v)| (k.to_ascii_lowercase(), v));
object_store::parse_url_opts(&parsed, env)
.with_context(|| format!("Failed to build object store for '{url}'"))
}
fn object_urls_overlap(a: &str, b: &str) -> bool {
let (pa, pb) = match (url::Url::parse(a), url::Url::parse(b)) {
(Ok(x), Ok(y)) => (x, y),
_ => return false,
};
if pa.scheme() != pb.scheme() || pa.host_str() != pb.host_str() {
return false;
}
let seg = |u: &url::Url| -> Vec<String> {
u.path_segments()
.map(|s| s.filter(|p| !p.is_empty()).map(str::to_string).collect())
.unwrap_or_default()
};
let (sa, sb) = (seg(&pa), seg(&pb));
sa.iter().zip(sb.iter()).all(|(x, y)| x == y)
}
fn extension_for(format: &FileFormat, compression: Compression, encrypted: bool) -> String {
let base = match format {
FileFormat::Normal | FileFormat::Json | FileFormat::Text => "jsonl",
FileFormat::Csv => "csv",
FileFormat::Raw => "bin",
};
let mut ext = match compression {
Compression::None => base.to_string(),
Compression::Gzip => format!("{base}.gz"),
Compression::Lz4 => format!("{base}.lz4"),
Compression::Zstd => format!("{base}.zst"),
};
if encrypted {
ext.push_str(".enc");
}
ext
}
fn validate_object_settings(_config: &ObjectStoreConfig) -> anyhow::Result<()> {
#[cfg(not(feature = "compression"))]
if _config.compression != Compression::None {
return Err(anyhow!(
"object_store 'compression' requires the `compression` feature"
));
}
#[cfg(not(feature = "encryption"))]
if _config.encryption.is_some() {
return Err(anyhow!(
"object_store 'encryption' requires the `encryption` feature"
));
}
Ok(())
}
fn split_records<'a>(data: &'a [u8], delimiter: &[u8]) -> Vec<&'a [u8]> {
let mut records = Vec::new();
if delimiter.is_empty() {
return records;
}
let newline = delimiter.len() == 1 && delimiter[0] == b'\n';
let mut start = 0;
let mut i = 0;
while i + delimiter.len() <= data.len() {
if &data[i..i + delimiter.len()] == delimiter {
let mut end = i;
if newline && end > start && data[end - 1] == b'\r' {
end -= 1;
}
records.push(&data[start..end]);
i += delimiter.len();
start = i;
} else {
i += 1;
}
}
if start < data.len() {
records.push(&data[start..]);
}
records
}
fn split_and_parse(data: &[u8], delimiter: &[u8], format: &FileFormat) -> Vec<CanonicalMessage> {
let mut out = Vec::new();
let mut csv_header: Option<Vec<String>> = None;
for record in split_records(data, delimiter) {
if let Some(msg) = parse_message(record, format, &mut csv_header) {
out.push(msg);
}
}
out
}
fn empty_batch() -> ReceivedBatch {
ReceivedBatch {
messages: Vec::new(),
commit: Box::new(|_| Box::pin(async { Ok(()) })),
}
}
#[derive(Clone)]
pub struct ObjectStorePublisher {
store: Arc<dyn ObjectStore>,
base: ObjPath,
delimiter: Vec<u8>,
format: FileFormat,
#[cfg(feature = "compression")]
compression: Compression,
#[cfg(feature = "encryption")]
crypto: Option<Arc<Crypto>>,
date_partition: bool,
extension: String,
}
impl ObjectStorePublisher {
pub async fn new(config: &ObjectStoreConfig) -> anyhow::Result<Self> {
if matches!(config.format, FileFormat::Csv) {
return Err(anyhow!(
"object_store sink does not support the 'csv' format (per-object CSV headers are unimplemented); use jsonl/json/text/raw"
));
}
validate_object_settings(config)?;
let (store, base) = build_store(&config.url)?;
let delimiter = parse_delimiter(config.delimiter.as_deref())?;
let extension = config.extension.clone().unwrap_or_else(|| {
extension_for(
&config.format,
config.compression,
config.encryption.is_some(),
)
});
info!(url = %config.url, format = ?config.format, "Object-store sink opened");
Ok(Self {
store: Arc::from(store),
base,
delimiter,
format: config.format.clone(),
#[cfg(feature = "compression")]
compression: config.compression,
#[cfg(feature = "encryption")]
crypto: config
.encryption
.as_ref()
.map(Crypto::new)
.transpose()?
.map(Arc::new),
date_partition: config.date_partition,
extension,
})
}
fn next_key(&self) -> ObjPath {
let id = fast_uuid_v7::gen_id();
let name = format!("{}.{}", fast_uuid_v7::format_uuid(id), self.extension);
if self.date_partition {
let (y, m, d) = civil_from_unix_ms((id >> 80) as u64);
self.base
.clone()
.join(format!("{y:04}").as_str())
.join(format!("{m:02}").as_str())
.join(format!("{d:02}").as_str())
.join(name.as_str())
} else {
self.base.clone().join(name.as_str())
}
}
}
fn civil_from_unix_ms(ms: u64) -> (i64, u32, u32) {
let days = (ms / 86_400_000) as i64;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
#[async_trait]
impl MessagePublisher for ObjectStorePublisher {
async fn send_batch(
&self,
messages: Vec<CanonicalMessage>,
) -> Result<SentBatch, PublisherError> {
if messages.is_empty() {
return Ok(SentBatch::Ack);
}
let mut body = Vec::new();
let mut failed = Vec::new();
for mut msg in messages {
msg.strip_source_metadata();
match encode_record(&msg, &self.format) {
Ok(bytes) => {
body.extend_from_slice(&bytes);
body.extend_from_slice(&self.delimiter);
}
Err(e) => {
failed.push((msg, PublisherError::NonRetryable(anyhow!(e))));
}
}
}
if body.is_empty() {
return Ok(SentBatch::Partial {
responses: None,
failed,
});
}
#[cfg(feature = "compression")]
let body = if self.compression != Compression::None {
crate::support::compression::compress_member(self.compression, &body)
.map_err(|e| PublisherError::NonRetryable(anyhow!(e)))?
} else {
body
};
#[allow(unused_mut)]
let mut body = body;
#[cfg(feature = "encryption")]
if let Some(crypto) = &self.crypto {
body = crypto
.seal(&body, b"")
.map_err(PublisherError::NonRetryable)?;
}
let key = self.next_key();
self.store
.put(&key, PutPayload::from(body))
.await
.map_err(|e| {
PublisherError::Retryable(anyhow!(e).context(format!("object-store put '{key}'")))
})?;
trace!(key = %key, "Wrote object to object store");
if failed.is_empty() {
Ok(SentBatch::Ack)
} else {
Ok(SentBatch::Partial {
responses: None,
failed,
})
}
}
async fn flush(&self) -> anyhow::Result<()> {
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct ObjProgress {
key: String,
remaining: usize,
}
pub struct ObjectStoreConsumer {
store: Arc<dyn ObjectStore>,
base: ObjPath,
delimiter: Vec<u8>,
format: FileFormat,
#[cfg(feature = "compression")]
compression: Compression,
#[cfg(feature = "encryption")]
crypto: Option<Arc<Crypto>>,
checkpoint: Option<Arc<dyn CheckpointStore>>,
last_key: Arc<Mutex<Option<String>>>,
buffer: Arc<Mutex<Vec<CanonicalMessage>>>,
progress: Arc<Mutex<Option<ObjProgress>>>,
idle_delay: Duration,
max_object_bytes: Option<u64>,
}
impl ObjectStoreConsumer {
pub async fn new(config: &ObjectStoreConfig) -> anyhow::Result<Self> {
validate_object_settings(config)?;
let (store, base) = build_store(&config.url)?;
let delimiter = parse_delimiter(config.delimiter.as_deref())?;
let checkpoint: Option<Arc<dyn CheckpointStore>> = match (
&config.cursor_id,
&config.checkpoint_store,
) {
(Some(cid), Some(spec)) => match checkpoint::parse_checkpoint_store(spec)? {
CheckpointBackend::Source { .. } => {
return Err(anyhow!(
"object_store source requires an external checkpoint_store (file://, s3://, postgres://, or mongodb://); a source-datastore checkpoint is not available."
));
}
external => {
if let CheckpointBackend::ObjectStore { url: ck_url } = &external {
if object_urls_overlap(&config.url, ck_url) {
return Err(anyhow!(
"object_store checkpoint_store '{ck_url}' overlaps the source prefix '{}'; the cursor object would be listed and re-read as data. Point checkpoint_store at a different bucket or prefix.",
config.url
));
}
}
Some(checkpoint::build_external_store(external, &config.url, cid).await?)
}
},
(Some(_), None) => {
warn!(
url = %config.url,
"object_store source has cursor_id but no checkpoint_store; resume is disabled and every restart re-emits all objects. Set an external checkpoint_store (file://, s3://, postgres://, mongodb://)."
);
None
}
(None, _) => {
warn!(
url = %config.url,
"object_store source has no cursor_id; resume is disabled and every restart re-emits all objects."
);
None
}
};
let last_key = match &checkpoint {
Some(cp) => cp.load().await?,
None => None,
};
info!(
url = %config.url,
has_checkpoint = %last_key.is_some(),
"Object-store source connected"
);
Ok(Self {
store: Arc::from(store),
base,
delimiter,
format: config.format.clone(),
#[cfg(feature = "compression")]
compression: config.compression,
#[cfg(feature = "encryption")]
crypto: config
.encryption
.as_ref()
.map(Crypto::new)
.transpose()?
.map(Arc::new),
checkpoint,
last_key: Arc::new(Mutex::new(last_key)),
buffer: Arc::new(Mutex::new(Vec::new())),
progress: Arc::new(Mutex::new(None)),
idle_delay: Duration::from_millis(config.polling_interval_ms.unwrap_or(1000)),
max_object_bytes: config.max_object_bytes,
})
}
#[cfg(test)]
fn from_store(
store: Arc<dyn ObjectStore>,
base: ObjPath,
format: FileFormat,
checkpoint: Option<Arc<dyn CheckpointStore>>,
last_key: Option<String>,
) -> Self {
Self {
store,
base,
delimiter: vec![b'\n'],
format,
#[cfg(feature = "compression")]
compression: Compression::None,
#[cfg(feature = "encryption")]
crypto: None,
checkpoint,
last_key: Arc::new(Mutex::new(last_key)),
buffer: Arc::new(Mutex::new(Vec::new())),
progress: Arc::new(Mutex::new(None)),
idle_delay: Duration::from_millis(10),
max_object_bytes: None,
}
}
async fn next_object(&self, last: Option<&str>) -> anyhow::Result<Option<(String, Vec<u8>)>> {
let mut stream = match last {
Some(k) => self
.store
.list_with_offset(Some(&self.base), &ObjPath::from(k)),
None => self.store.list(Some(&self.base)),
};
while let Some(meta) = stream.next().await {
let meta = meta?;
let key = meta.location.to_string();
if key.ends_with('/') || last == Some(key.as_str()) {
continue;
}
if let Some(limit) = self.max_object_bytes {
if meta.size > limit {
return Err(anyhow!(
"object '{key}' is {} bytes, exceeding max_object_bytes ({limit}); refusing to buffer it whole",
meta.size
));
}
}
let data = self
.store
.get(&meta.location)
.await?
.bytes()
.await?
.to_vec();
#[cfg(feature = "encryption")]
let data = if let Some(crypto) = &self.crypto {
crypto
.open(&data, b"")
.with_context(|| format!("decrypt object '{key}'"))?
} else {
data
};
#[cfg(feature = "compression")]
let data = if self.compression != Compression::None {
crate::support::compression::decompress_all(
self.compression,
&data,
self.max_object_bytes,
)
.with_context(|| format!("decompress object '{key}'"))?
} else {
data
};
return Ok(Some((key, data)));
}
Ok(None)
}
async fn save_cursor(&self, key: &str) -> anyhow::Result<()> {
if let Some(cp) = &self.checkpoint {
cp.save(key)
.await
.with_context(|| format!("persist object-store cursor '{key}'"))?;
}
*self.last_key.lock().await = Some(key.to_string());
Ok(())
}
}
#[async_trait]
impl MessageConsumer for ObjectStoreConsumer {
async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
if max_messages == 0 {
return Ok(empty_batch());
}
{
let buffer_empty = self.buffer.lock().await.is_empty();
let in_flight = self.progress.lock().await.is_some();
if buffer_empty && !in_flight {
let last = self.last_key.lock().await.clone();
match self
.next_object(last.as_deref())
.await
.map_err(ConsumerError::Connection)?
{
None => {
tokio::time::sleep(self.idle_delay).await;
return Ok(empty_batch());
}
Some((key, data)) => {
let records = split_and_parse(&data, &self.delimiter, &self.format);
if records.is_empty() {
self.save_cursor(&key)
.await
.map_err(ConsumerError::Connection)?;
tokio::time::sleep(self.idle_delay).await;
return Ok(empty_batch());
}
let n = records.len();
*self.buffer.lock().await = records;
*self.progress.lock().await = Some(ObjProgress { key, remaining: n });
}
}
} else if buffer_empty {
tokio::time::sleep(self.idle_delay).await;
return Ok(empty_batch());
}
}
let batch: Vec<CanonicalMessage> = {
let mut buffer = self.buffer.lock().await;
let count = buffer.len().min(max_messages);
buffer.drain(0..count).collect()
};
let buffer_arc = self.buffer.clone();
let progress_arc = self.progress.clone();
let last_key_arc = self.last_key.clone();
let checkpoint = self.checkpoint.clone();
let batch_for_commit = batch.clone();
let commit = Box::new(move |dispositions: Vec<MessageDisposition>| {
Box::pin(async move {
let mut leading_acks = 0usize;
let mut requeue = Vec::new();
let mut hit_nack = false;
for (i, d) in dispositions.iter().enumerate() {
if hit_nack {
if let Some(m) = batch_for_commit.get(i) {
requeue.push(m.clone());
}
continue;
}
match d {
MessageDisposition::Ack | MessageDisposition::Reply(_) => leading_acks += 1,
MessageDisposition::Nack => {
hit_nack = true;
if let Some(m) = batch_for_commit.get(i) {
requeue.push(m.clone());
}
}
}
}
if dispositions.len() < batch_for_commit.len() {
for m in &batch_for_commit[dispositions.len()..] {
requeue.push(m.clone());
}
}
if !requeue.is_empty() {
let mut buf = buffer_arc.lock().await;
let old = std::mem::take(&mut *buf);
let mut new = requeue;
new.extend(old);
*buf = new;
}
if leading_acks > 0 {
let mut prog = progress_arc.lock().await;
if let Some(p) = prog.as_mut() {
p.remaining = p.remaining.saturating_sub(leading_acks);
if p.remaining == 0 {
let key = p.key.clone();
if let Some(cp) = &checkpoint {
if let Err(e) = cp.save(&key).await {
*prog = None;
return Err(anyhow!(e).context("persist object-store cursor"));
}
}
*prog = None;
drop(prog);
*last_key_arc.lock().await = Some(key);
}
}
}
Ok(())
}) as BoxFuture<'static, anyhow::Result<()>>
});
Ok(ReceivedBatch {
messages: batch,
commit,
})
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::MessageConsumer;
use object_store::memory::InMemory;
#[test]
fn checkpoint_overlap_detection() {
assert!(object_urls_overlap(
"s3://bucket/data",
"s3://bucket/data/cursors"
));
assert!(object_urls_overlap("s3://bucket/data", "s3://bucket/data"));
assert!(!object_urls_overlap(
"s3://bucket/data",
"s3://bucket/cursors"
));
assert!(!object_urls_overlap(
"s3://bucket/data",
"s3://bucket/database"
));
assert!(!object_urls_overlap("s3://bucket/data", "s3://other/data"));
}
#[test]
fn civil_date_from_unix_ms() {
assert_eq!(civil_from_unix_ms(1_784_246_400_000), (2026, 7, 17));
assert_eq!(civil_from_unix_ms(0), (1970, 1, 1));
assert_eq!(civil_from_unix_ms(1_709_208_000_000), (2024, 2, 29));
}
#[test]
fn extension_reflects_compression_and_encryption() {
assert_eq!(
extension_for(&FileFormat::Normal, Compression::None, false),
"jsonl"
);
assert_eq!(
extension_for(&FileFormat::Normal, Compression::Gzip, false),
"jsonl.gz"
);
assert_eq!(
extension_for(&FileFormat::Raw, Compression::Lz4, false),
"bin.lz4"
);
assert_eq!(
extension_for(&FileFormat::Normal, Compression::Zstd, false),
"jsonl.zst"
);
assert_eq!(
extension_for(&FileFormat::Normal, Compression::Gzip, true),
"jsonl.gz.enc"
);
assert_eq!(
extension_for(&FileFormat::Raw, Compression::None, true),
"bin.enc"
);
}
fn json_msg(v: serde_json::Value) -> CanonicalMessage {
CanonicalMessage::new(serde_json::to_vec(&v).unwrap(), None)
}
fn test_publisher(store: Arc<dyn ObjectStore>) -> ObjectStorePublisher {
ObjectStorePublisher {
store,
base: ObjPath::from("data"),
delimiter: vec![b'\n'],
format: FileFormat::Normal,
#[cfg(feature = "compression")]
compression: Compression::None,
#[cfg(feature = "encryption")]
crypto: None,
date_partition: false,
extension: "jsonl".to_string(),
}
}
#[cfg(feature = "compression")]
#[tokio::test]
async fn compressed_object_round_trips() {
for compression in [Compression::Gzip, Compression::Lz4, Compression::Zstd] {
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let mut publisher = test_publisher(store.clone());
publisher.format = FileFormat::Raw;
publisher.compression = compression;
publisher.extension = extension_for(&FileFormat::Raw, compression, false);
publisher
.send_batch(vec![
json_msg(serde_json::json!({"n": 1})),
json_msg(serde_json::json!({"n": 2})),
])
.await
.unwrap();
let listed = store
.list(Some(&ObjPath::from("data")))
.next()
.await
.unwrap()
.unwrap();
let suffix = match compression {
Compression::Gzip => ".bin.gz",
Compression::Zstd => ".bin.zst",
_ => ".bin.lz4",
};
assert!(listed.location.to_string().ends_with(suffix));
let bytes = store
.get(&listed.location)
.await
.unwrap()
.bytes()
.await
.unwrap();
let decoded =
crate::support::compression::decompress_all(compression, &bytes, None).unwrap();
assert_eq!(String::from_utf8(decoded).unwrap().lines().count(), 2);
let mut consumer = ObjectStoreConsumer::from_store(
store,
ObjPath::from("data"),
FileFormat::Raw,
None,
None,
);
consumer.compression = compression;
let batch = consumer.receive_batch(10).await.unwrap();
assert_eq!(batch.messages.len(), 2);
assert_eq!(batch.messages[0].payload.as_ref(), br#"{"n":1}"#);
assert_eq!(batch.messages[1].payload.as_ref(), br#"{"n":2}"#);
(batch.commit)(vec![MessageDisposition::Ack; 2])
.await
.unwrap();
}
}
#[cfg(all(feature = "compression", feature = "encryption"))]
#[tokio::test]
async fn encrypted_object_round_trips() {
use base64::Engine as _;
let crypto_cfg = crate::models::EncryptionConfig {
key: base64::engine::general_purpose::STANDARD.encode([42u8; 32]),
..Default::default()
};
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let mut publisher = test_publisher(store.clone());
publisher.format = FileFormat::Raw;
publisher.compression = Compression::Gzip;
publisher.crypto = Some(Arc::new(Crypto::new(&crypto_cfg).unwrap()));
publisher
.send_batch(vec![json_msg(serde_json::json!({"who": "alice"}))])
.await
.unwrap();
let listed = store
.list(Some(&ObjPath::from("data")))
.next()
.await
.unwrap()
.unwrap();
let bytes = store
.get(&listed.location)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert!(
crate::support::compression::decompress_all(Compression::Gzip, &bytes, None).is_err()
);
assert!(!bytes.windows(5).any(|w| w == b"alice"));
let mut consumer = ObjectStoreConsumer::from_store(
store.clone(),
ObjPath::from("data"),
FileFormat::Raw,
None,
None,
);
consumer.compression = Compression::Gzip;
consumer.crypto = Some(Arc::new(Crypto::new(&crypto_cfg).unwrap()));
let batch = consumer.receive_batch(10).await.unwrap();
assert_eq!(batch.messages.len(), 1);
assert_eq!(batch.messages[0].payload.as_ref(), br#"{"who":"alice"}"#);
let mut wrong = ObjectStoreConsumer::from_store(
store,
ObjPath::from("data"),
FileFormat::Raw,
None,
None,
);
wrong.compression = Compression::Gzip;
let wrong_cfg = crate::models::EncryptionConfig {
key: base64::engine::general_purpose::STANDARD.encode([1u8; 32]),
..Default::default()
};
wrong.crypto = Some(Arc::new(Crypto::new(&wrong_cfg).unwrap()));
assert!(wrong.receive_batch(10).await.is_err());
}
#[cfg(feature = "compression")]
#[tokio::test]
async fn gzip_source_errors_on_non_gzip_object() {
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
store
.put(
&ObjPath::from("data/not-gzip.jsonl.gz"),
PutPayload::from(br#"{"n":1}"#.to_vec()),
)
.await
.unwrap();
let mut consumer = ObjectStoreConsumer::from_store(
store,
ObjPath::from("data"),
FileFormat::Raw,
None,
None,
);
consumer.compression = Compression::Gzip;
assert!(
consumer.receive_batch(10).await.is_err(),
"expected a decode error for a non-gzip object"
);
}
#[tokio::test]
async fn sink_writes_object_and_source_round_trips() {
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let publisher = test_publisher(store.clone());
publisher
.send_batch(vec![
json_msg(serde_json::json!({"n": 1})),
json_msg(serde_json::json!({"n": 2})),
])
.await
.unwrap();
let mut listed = store.list(Some(&ObjPath::from("data")));
let first = listed.next().await.unwrap().unwrap();
assert!(first.location.to_string().starts_with("data/"));
let mut consumer = ObjectStoreConsumer::from_store(
store,
ObjPath::from("data"),
FileFormat::Normal,
None,
None,
);
let batch = consumer.receive_batch(10).await.unwrap();
assert_eq!(batch.messages.len(), 2);
assert_eq!(batch.messages[0].payload.as_ref(), br#"{"n":1}"#);
assert_eq!(batch.messages[1].payload.as_ref(), br#"{"n":2}"#);
(batch.commit)(vec![MessageDisposition::Ack; 2])
.await
.unwrap();
let drained = consumer.receive_batch(10).await.unwrap();
assert!(drained.messages.is_empty());
}
#[tokio::test]
async fn nacked_records_are_redelivered() {
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let publisher = test_publisher(store.clone());
publisher
.send_batch(vec![
json_msg(serde_json::json!({"n": 1})),
json_msg(serde_json::json!({"n": 2})),
])
.await
.unwrap();
let mut consumer = ObjectStoreConsumer::from_store(
store,
ObjPath::from("data"),
FileFormat::Normal,
None,
None,
);
let batch = consumer.receive_batch(10).await.unwrap();
assert_eq!(batch.messages.len(), 2);
(batch.commit)(vec![MessageDisposition::Ack, MessageDisposition::Nack])
.await
.unwrap();
let retry = consumer.receive_batch(10).await.unwrap();
assert_eq!(retry.messages.len(), 1);
assert_eq!(retry.messages[0].payload.as_ref(), br#"{"n":2}"#);
(retry.commit)(vec![MessageDisposition::Ack]).await.unwrap();
let drained = consumer.receive_batch(10).await.unwrap();
assert!(drained.messages.is_empty());
}
}