Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
0845138
test(02-01): add failing tests for negative ack backoff
abbudao May 29, 2026
35f204d
feat(02-01): implement negative ack backoff math
abbudao May 29, 2026
31ccb46
feat(02-01): expose negative ack backoff at crate root
abbudao May 29, 2026
5b47334
test(03-01): add nack delay validation helper
abbudao May 30, 2026
eda14a6
feat(03-02): wire negative ack configuration
abbudao May 30, 2026
449850a
feat(03-03): propagate redelivery count through consumer
abbudao May 30, 2026
c6d876f
fix(03): resolve code review findings
abbudao May 30, 2026
bb29ce3
test(04-01): add failing negative ack delay selection tests
abbudao Jun 1, 2026
a6cadaf
feat(04-01): implement negative ack delay selection
abbudao Jun 1, 2026
c1c8161
test(04-01): add failing negative ack tracker operation tests
abbudao Jun 1, 2026
71f0272
feat(04-01): implement negative ack tracker operations
abbudao Jun 1, 2026
dae3f21
test(04-02): add failing engine config plumbing tests
abbudao Jun 1, 2026
35df2b3
feat(04-02): pass negative ack config into engine
abbudao Jun 1, 2026
8334755
test(04-02): add failing engine delayed nack runtime assertions
abbudao Jun 1, 2026
a26200d
feat(04-02): integrate delayed negative ack runtime
abbudao Jun 1, 2026
bed9eb0
test(04-03): add failing runtime docs compatibility assertions
abbudao Jun 1, 2026
d8c87c5
docs(04-03): document negative ack runtime semantics
abbudao Jun 1, 2026
2394d7a
refactor(04-03): gate tracker test helpers
abbudao Jun 1, 2026
42b49f3
fix(04-03): satisfy validation scope guards
abbudao Jun 1, 2026
10d0b1f
test(04-03): assert reconnect preserves pending nacks
abbudao Jun 1, 2026
e8720dc
fix(04): address negative ack code review findings
abbudao Jun 1, 2026
0e4358e
fix(04): clear delayed nack after immediate redelivery
abbudao Jun 1, 2026
55865d8
fix(04): avoid duplicate nack backoff evaluation
abbudao Jun 1, 2026
665d60b
fix(04): remove nacks from unacked timeout tracker
abbudao Jun 1, 2026
e50f729
fix(04): harden negative ack retry semantics
abbudao Jun 1, 2026
2488002
fix(04): address nack follow-up review
abbudao Jun 1, 2026
1dba714
test(05-01): add bounded negative-ack tracker regression tests
abbudao Jun 1, 2026
c72b79d
test(05-01): guard negative-ack DLQ separation
abbudao Jun 1, 2026
b52738b
test(05-01): prove negative-ack config across consumer flavors
abbudao Jun 1, 2026
56c9302
test(05-01): bound DLQ tests with short nack delay
abbudao Jun 1, 2026
5b15d0c
test(05-02): add negative ack usage example
abbudao Jun 1, 2026
d671e26
docs(05-02): document negative ack delay migration
abbudao Jun 1, 2026
7580e2d
fix(05): WR-01 reject unrepresentable nack delay
abbudao Jun 1, 2026
beca11e
fix(05): WR-02 add topic consumer config seam
abbudao Jun 1, 2026
9283459
fix(05): WR-03 replace DLQ source checks with behavior tests
abbudao Jun 1, 2026
c7d7481
fix(05): WR-04 compile alternate negative ack examples
abbudao Jun 1, 2026
b1a8a98
fix(05): WR-03 return reader regex error instead of panicking
abbudao Jun 1, 2026
4e86a97
fix(05): WR-01 exercise consumer construction config paths
abbudao Jun 1, 2026
4d6783e
fix(05): WR-02 add engine effect seam for nack DLQ tests
abbudao Jun 1, 2026
1f7bf1f
fix(05): tighten review fix behavior seams
abbudao Jun 1, 2026
595b827
fix(05): preserve regex consumer with single topic
abbudao Jun 1, 2026
f413ecb
fix(05): clear cumulative ack redelivery entries
abbudao Jun 1, 2026
f3adc70
fix(05): clean ack state after broker ack succeeds
abbudao Jun 1, 2026
4c000a9
fix(05): test ack cleanup with effect seam
abbudao Jun 1, 2026
a010ae0
test(05): exercise default nack delay in DLQ tests
abbudao Jun 2, 2026
2323a41
fix: improve nack delay validation error
abbudao Jun 8, 2026
d521351
fix(06-01): restore public receiver compatibility
abbudao Jun 8, 2026
5332da5
fix(06-02): restore regex and reader construction scope
abbudao Jun 8, 2026
8414131
refactor(06-02): remove topic consumer test seams
abbudao Jun 8, 2026
f5b936b
refactor(06-02): trim negative ack public surface
abbudao Jun 8, 2026
4e28383
refactor(06-03): trim lifecycle close path to nack cleanup
abbudao Jun 8, 2026
f1c6b0d
refactor(06-03): narrow ack cleanup to nack cancellation
abbudao Jun 8, 2026
35f1641
test(06-04): make broker DLQ tests use short nack delay
abbudao Jun 8, 2026
7a461bc
test(06-04): remove consumer source-string assertions
abbudao Jun 8, 2026
0ec4849
fix(06-review): close negative ack backoff review gaps
abbudao Jun 8, 2026
9827789
refactor(06-review): remove obsolete redelivery count helper
abbudao Jun 8, 2026
aae55dd
chore: cleanup README
abbudao Jun 9, 2026
70d09da
chore: reorder tests
abbudao Jun 9, 2026
8ae5a38
docs: tighten nack builder API docs
abbudao Jun 9, 2026
8997266
fix: make nack default delay fallback overflow-safe
abbudao Jun 9, 2026
e6209c1
docs: align negative ack example style
abbudao Jun 9, 2026
afb0531
refactor: simplify nack backoff trait object type
abbudao Jun 9, 2026
efbc98f
docs(consumer): clarify nack redelivery delay and backoff doc semantics
abbudao Jun 10, 2026
614289a
docs(readme): describe pluggable negative-ack backoff policies
abbudao Jun 10, 2026
31bee6e
fix(consumer): clear negative ack state when consumer closes
abbudao Jun 10, 2026
fe74ac2
chore: fix readme
abbudao Jun 10, 2026
73b19ea
Merge branch 'master' into nack-backoff
abbudao Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Features:
- Automatic reconnection with exponential back off;
- Message batching;
- Compression with LZ4, zlib, zstd or Snappy (can be deactivated with Cargo features);
- Telemetry using [tracing](https://github.com/tokio-rs/tracing) crate (can be activated with Cargo features).
- Telemetry using [tracing](https://github.com/tokio-rs/tracing) crate (can be activated with Cargo features);
- Configurable negative-acknowledgment redelivery delay with pluggable backoff policies

## Getting Started

Expand All @@ -31,6 +32,7 @@ Try out [examples](examples):
- [producer](examples/producer.rs)
- [consumer](examples/consumer.rs)
- [reader](examples/reader.rs)
- [negative_ack](examples/negative_ack.rs)

## Project Maintainers

Expand Down
103 changes: 103 additions & 0 deletions examples/negative_ack.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#[macro_use]
extern crate serde;
use std::{env, time::Duration};

use futures::TryStreamExt;
use pulsar::{
authentication::{basic::BasicAuthentication, oauth2::OAuth2Authentication},
Authentication, Consumer, DeserializeMessage, MultiplierRedeliveryBackoff, Payload, Pulsar,
SubType, TokioExecutor,
};

#[derive(Serialize, Deserialize)]
struct TestData {
data: String,
}

impl DeserializeMessage for TestData {
type Output = Result<TestData, serde_json::Error>;

fn deserialize_message(payload: &Payload) -> Self::Output {
serde_json::from_slice(&payload.data)
}
}

#[tokio::main]
async fn main() -> Result<(), pulsar::Error> {
env_logger::init();

let addr = env::var("PULSAR_ADDRESS")
.ok()
.unwrap_or_else(|| "pulsar://127.0.0.1:6650".to_string());
let topic = env::var("PULSAR_TOPIC")
.ok()
.unwrap_or_else(|| "non-persistent://public/default/test".to_string());

let mut builder = Pulsar::builder(addr, TokioExecutor);

if let Ok(token) = env::var("PULSAR_TOKEN") {
let authentication = Authentication {
name: "token".to_string(),
data: token.into_bytes(),
};

builder = builder.with_auth(authentication);
} else if let Ok(oauth2_cfg) = env::var("PULSAR_OAUTH2") {
builder = builder.with_auth_provider(OAuth2Authentication::client_credentials(
serde_json::from_str(oauth2_cfg.as_str())
.unwrap_or_else(|_| panic!("invalid oauth2 config [{}]", oauth2_cfg.as_str())),
));
} else if let (Ok(username), Ok(password)) = (
env::var("PULSAR_BASIC_USERNAME"),
env::var("PULSAR_BASIC_PASSWORD"),
) {
builder = builder.with_auth_provider(BasicAuthentication::new(&username, &password))
}

let pulsar: Pulsar<_> = builder.build().await?;
let negative_ack_backoff = MultiplierRedeliveryBackoff::builder()
.min_delay(Duration::from_secs(1))
.max_delay(Duration::from_secs(30))
.multiplier(2.0)
.build()?;

let mut consumer: Consumer<TestData, _> = pulsar
.consumer()
.with_topic(topic)
.with_consumer_name("negative_ack_consumer")
.with_subscription_type(SubType::Exclusive)
.with_subscription("negative_ack_subscription")
.with_nack_redelivery_delay(Duration::from_secs(5))
.with_negative_ack_backoff(negative_ack_backoff)
.build()
.await?;

let mut counter = 0usize;
while let Some(msg) = consumer.try_next().await? {
log::info!("metadata: {:?}", msg.metadata());
log::info!("id: {:?}", msg.message_id());
log::info!("redelivery_count: {}", msg.redelivery_count());

let data = match msg.deserialize() {
Ok(data) => data,
Err(e) => {
log::error!("could not deserialize message: {:?}", e);
consumer.nack(&msg).await?;
break;
}
};

log::info!("payload data: {}", data.data);
consumer.nack(&msg).await?;

counter += 1;
log::info!("nacked {} messages", counter);

if counter > 10 {
consumer.close().await.expect("Unable to close consumer");
break;
}
}

Ok(())
}
174 changes: 172 additions & 2 deletions src/consumer/builder.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::{
collections::{BTreeMap, VecDeque},
time::Duration,
sync::Arc,
time::{Duration, Instant},
};

use futures::{future::try_join_all, StreamExt};
Expand All @@ -10,13 +11,19 @@ use regex::Regex;
use crate::{
consumer::{
config::ConsumerConfig, data::DeadLetterPolicy, multi::MultiTopicConsumer,
options::ConsumerOptions, topic::TopicConsumer, InnerConsumer,
negative_ack_backoff::NegativeAckBackoff, options::ConsumerOptions, topic::TopicConsumer,
InnerConsumer,
},
message::proto::command_subscribe::SubType,
reader::{Reader, State},
BrokerAddress, Consumer, DeserializeMessage, Error, Executor, Pulsar,
};

const NACK_REDELIVERY_DELAY_EXCEEDS_MILLISECONDS_RANGE: &str =
"negative-ack redelivery delay is too large: it must be less than u64::MAX milliseconds";
const NACK_REDELIVERY_DELAY_EXCEEDS_INSTANT_RANGE: &str =
"negative-ack redelivery delay is too large to schedule from the current instant";

/// Builder structure for consumers
///
/// This is the main way to create a [Consumer] or a [Reader]
Expand All @@ -35,6 +42,24 @@ pub struct ConsumerBuilder<Exe: Executor> {
consumer_options: Option<ConsumerOptions>,
namespace: Option<String>,
topic_refresh: Option<Duration>,
nack_redelivery_delay: Option<Duration>,
negative_ack_backoff: Option<Arc<dyn NegativeAckBackoff>>,
}

fn check_nack_delay_duration(delay: Duration) -> Result<(), Error> {
if delay.as_millis() >= u64::MAX as u128 {
return Err(Error::Custom(format!(
"{NACK_REDELIVERY_DELAY_EXCEEDS_MILLISECONDS_RANGE}: {delay:?}"
)));
}

if Instant::now().checked_add(delay).is_none() {
return Err(Error::Custom(format!(
"{NACK_REDELIVERY_DELAY_EXCEEDS_INSTANT_RANGE}: {delay:?}"
)));
}

Ok(())
}

impl<Exe: Executor> ConsumerBuilder<Exe> {
Expand All @@ -56,6 +81,8 @@ impl<Exe: Executor> ConsumerBuilder<Exe> {
consumer_options: None,
namespace: None,
topic_refresh: None,
nack_redelivery_delay: None,
negative_ack_backoff: None,
}
}

Expand Down Expand Up @@ -175,6 +202,43 @@ impl<Exe: Executor> ConsumerBuilder<Exe> {
self
}

/// Sets the fixed delay before redelivering negatively acknowledged messages.
///
/// If this option is not set, negative acknowledgments use a 60 seconds default delay before
/// broker redelivery.
///
/// `Duration::ZERO` is valid and requests immediate redelivery. Repeated calls are
/// last-call-wins.
///
/// The configured fixed delay is used for ID-only [`Self::nack_with_id`] calls, which carry no
/// redelivery count. It is also used as the delay for message-based [`Consumer::nack`] calls
/// when no backoff policy has been configured.
#[cfg_attr(feature = "telemetry", tracing::instrument(skip_all))]
pub fn with_nack_redelivery_delay(mut self, delay: Duration) -> Self {
self.nack_redelivery_delay = Some(delay);
self
}

/// Configures an optional negative-ack backoff policy.
///
/// Message-based negative acknowledgments use the message's redelivery count (`0` on first
/// delivery) to compute the backoff delay. ID-only `nack_with_id` calls have no redelivery
/// count, so they use
/// [`Self::with_nack_redelivery_delay`] or the 60 seconds default instead.
///
/// A backoff that returns `Duration::ZERO` requests immediate redelivery.
///
/// This policy affects only negative acknowledgments. Unacked-message redelivery and
/// dead-letter policy behavior remain configured separately.
#[cfg_attr(feature = "telemetry", tracing::instrument(skip_all))]
pub fn with_negative_ack_backoff<B>(mut self, backoff: B) -> Self
where
B: NegativeAckBackoff + 'static,
{
self.negative_ack_backoff = Some(Arc::new(backoff) as Arc<dyn NegativeAckBackoff>);
self
}

// Checks the builder for inconsistencies
// returns a config and a list of topics with associated brokers
#[cfg_attr(feature = "telemetry", tracing::instrument(skip_all))]
Expand All @@ -193,6 +257,8 @@ impl<Exe: Executor> ConsumerBuilder<Exe> {
dead_letter_policy,
namespace: _,
topic_refresh: _,
nack_redelivery_delay,
negative_ack_backoff,
} = self;

if consumer_name.is_none() {
Expand All @@ -208,6 +274,9 @@ impl<Exe: Executor> ConsumerBuilder<Exe> {
"Cannot create consumer with no topics and no topic regex".into(),
));
}
if let Some(delay) = nack_redelivery_delay {
check_nack_delay_duration(delay)?;
}

let topics: Vec<(String, BrokerAddress)> = try_join_all(
topics
Expand Down Expand Up @@ -263,6 +332,8 @@ impl<Exe: Executor> ConsumerBuilder<Exe> {
unacked_message_redelivery_delay: unacked_message_resend_delay,
options: consumer_options.unwrap_or_default(),
dead_letter_policy,
nack_redelivery_delay,
negative_ack_backoff,
};
Ok((config, topics))
}
Expand Down Expand Up @@ -347,3 +418,102 @@ impl<Exe: Executor> ConsumerBuilder<Exe> {
})
}
}

#[cfg(test)]
mod tests {
use std::{sync::Arc, time::Duration};

use super::*;

#[test]
fn test_nack_delay_validation_oversized() {
let delay = Duration::MAX;
let result = check_nack_delay_duration(delay);

assert_nack_delay_error(
result,
NACK_REDELIVERY_DELAY_EXCEEDS_MILLISECONDS_RANGE,
delay,
);
}

#[test]
fn test_nack_delay_validation_rejects_millisecond_range_boundary() {
let delay = Duration::from_millis(u64::MAX);
let result = check_nack_delay_duration(delay);

assert_nack_delay_error(
result,
NACK_REDELIVERY_DELAY_EXCEEDS_MILLISECONDS_RANGE,
delay,
);
}

fn assert_nack_delay_error(result: Result<(), Error>, expected_prefix: &str, delay: Duration) {
match result {
Err(Error::Custom(message)) => {
assert!(
message.starts_with(expected_prefix),
"expected error to start with {expected_prefix:?}, got {message:?}"
);
assert!(
message.contains(&format!("{delay:?}")),
"expected error to include the rejected Duration, got {message:?}"
);
}
other => panic!("expected Error::Custom({expected_prefix:?}), got {other:?}"),
}
}

#[test]
fn test_nack_delay_validation_zero_is_valid() {
let result = check_nack_delay_duration(Duration::ZERO);

assert!(result.is_ok());
}

#[test]
fn test_consumer_config_nack_delay_default_is_none() {
let config = ConsumerConfig::default();

assert!(config.nack_redelivery_delay.is_none());
assert!(config.negative_ack_backoff.is_none());
}

#[test]
fn test_consumer_config_stores_nack_delay() {
let config = ConsumerConfig {
nack_redelivery_delay: Some(Duration::from_secs(5)),
..ConsumerConfig::default()
};

assert_eq!(config.nack_redelivery_delay, Some(Duration::from_secs(5)));
}

#[test]
fn test_consumer_config_stores_both_nack_configs_independently() {
let config = ConsumerConfig {
nack_redelivery_delay: Some(Duration::from_secs(5)),
negative_ack_backoff: Some(Arc::new(
crate::consumer::negative_ack_backoff::MultiplierRedeliveryBackoff::default(),
)),
..ConsumerConfig::default()
};

assert!(config.nack_redelivery_delay.is_some());
assert!(config.negative_ack_backoff.is_some());
}

#[test]
fn test_consumer_config_backoff_only_stores_none_for_delay() {
let config = ConsumerConfig {
negative_ack_backoff: Some(Arc::new(
crate::consumer::negative_ack_backoff::MultiplierRedeliveryBackoff::default(),
)),
..ConsumerConfig::default()
};

assert!(config.nack_redelivery_delay.is_none());
assert!(config.negative_ack_backoff.is_some());
}
}
Loading