Commits and delivery guarantees
This guide chooses what ack and nack commit on Kafka: librdkafka's own commits, precise at-least-once with Commit::Tracked, and batches settled element by element.
Kafka does not settle single messages.
A consumer group commits a position per partition, and everything below it counts as read.
A handler still settles each delivery, so the subscription's Commit mode decides how those settlements become a position.
| Mode | ack | drop | retry | After a crash |
|---|---|---|---|---|
Auto (default) | nothing | nothing | reports unsupported, the record does not come back | the tail of handled records is read again, or records stored but not handled are skipped |
Tracked | settles the offset | settles the offset | the record comes back | every record not settled is read again |
Transactional | settles the offset, committed with the pipeline's transaction | the same as ack | stalls the window until the transaction deadline | the open window is read again |
The example is a payouts service: payouts must not be lost, page views may be. Transactions and exactly-once covers the third mode.
At-least-once with Commit::Tracked
pay_out sends a payout through the bank and answers with a confirmation.
When the bank does not answer, the payout comes back and is tried again.
- Macros
- Manual
#[derive(Debug, PartialEq, Deserialize)]
struct Payout {
id: u64,
amount_cents: u64,
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "payouts.done")]
struct Paid {
payout_id: u64,
}
#[subscriber(KafkaTopic::new("payouts").commit(Commit::Tracked), reply)]
async fn pay_out(payout: &Payout, State(bank): State<Arc<Bank>>) -> Result<Paid, HandlerOutcome> {
if payout.amount_cents == 0 {
return Err(HandlerOutcome::drop());
}
if bank.0.call().await.is_err() {
tracing::warn!(payout.id, "bank unavailable, payout comes back");
return Err(HandlerOutcome::retry());
}
tracing::info!(payout.id, payout.amount_cents, "payout sent");
Ok(Paid {
payout_id: payout.id,
})
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Payout {
id: u64,
amount_cents: u64,
}
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Paid {
payout_id: u64,
}
impl OutgoingDestination for Paid {
type Form = FixedName;
const DESTINATION: &'static str = "payouts.done";
}
impl MessageHeaders for Paid {
type Contract = NoHeaders;
}
impl MessageInfo for Paid {
const NAME: &'static str = "Paid";
}
struct PayOut;
impl Handle<Payout, Paid, (), (), Clients> for PayOut {
async fn handle(
&self,
payout: &Payout,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> Result<Paid, HandlerOutcome> {
if payout.amount_cents == 0 {
return Err(HandlerOutcome::drop());
}
if ctx.state().bank.call().await.is_err() {
tracing::warn!(payout.id, "bank unavailable, payout comes back");
return Err(HandlerOutcome::retry());
}
tracing::info!(payout.id, payout.amount_cents, "payout sent");
Ok(Paid {
payout_id: payout.id,
})
}
}
Under Commit::Tracked an ack moves the committed position up to the lowest delivery that is still open.
Acks from concurrent workers arrive out of order, and the position never passes a payout that is still in flight.
retry hands the delivery back to its subscription, which delivers it again before anything it fetches next.
Until then its offset stays open, so the position does not move past it.
A process that stops first gets the payout back from Kafka when it starts again.
So a payout is never lost, and it may be handled twice: once before a crash and once after.
The bank call has to accept a repeated payout, for example by its id.
A retry without an attempt cap comes back forever; Retries and dead-letter topics adds the cap.
librdkafka's own commits
count_view counts page views in an analytics store.
A lost view does not matter here, so the subscription keeps the default mode.
- Macros
- Manual
#[derive(Debug, PartialEq, Deserialize)]
struct PageView {
page: String,
}
#[subscriber(KafkaTopic::new("page-views").group("analytics"))]
async fn count_view(view: &PageView, State(counters): State<Arc<Counters>>) -> HandlerOutcome {
if counters.0.call().await.is_err() {
// Under `Commit::Auto` nothing brings the view back; analytics accepts the loss.
return HandlerOutcome::retry();
}
tracing::debug!(page = %view.page, "page view counted");
HandlerOutcome::ack()
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct PageView {
page: String,
}
struct CountView;
impl Handle<PageView, (), (), (), Clients> for CountView {
async fn handle(
&self,
view: &PageView,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> Result<(), HandlerOutcome> {
if ctx.state().counters.call().await.is_err() {
// Under `Commit::Auto` nothing brings the view back; analytics accepts the loss.
return Err(HandlerOutcome::retry());
}
tracing::debug!(page = %view.page, "page view counted");
Ok(())
}
}
Under Commit::Auto librdkafka stores the position when it hands the record to the application and commits it on a timer.
ack and drop change nothing.
retry reports itself unsupported, and the view does not come back.
This mode costs the least, and a crash can lose handled records or skip records that were stored but not handled.
Batches
reconcile takes statement lines in batches of up to 500 and settles each line on its own.
- Macros
- Manual
#[derive(Debug, PartialEq, Deserialize)]
struct StatementLine {
payout_id: u64,
amount_cents: u64,
}
#[subscriber(KafkaTopic::new("statements").group("reconciliation").commit(Commit::Tracked))]
async fn reconcile(lines: &[StatementLine]) -> Vec<HandlerOutcome> {
lines
.iter()
.map(|line| {
if line.amount_cents == 0 {
tracing::warn!(line.payout_id, "empty statement line dropped");
return HandlerOutcome::drop();
}
HandlerOutcome::ack()
})
.collect()
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct StatementLine {
payout_id: u64,
amount_cents: u64,
}
struct Reconcile;
impl Handle<[StatementLine], (), (), (), Clients> for Reconcile {
fn handle(
&self,
lines: &[StatementLine],
_outs: &(),
_ctx: &mut Context<'_, (), Clients>,
) -> impl Future<Output = Result<(), Vec<HandlerOutcome>>> {
if lines.iter().all(|line| line.amount_cents > 0) {
return ready(Ok(()));
}
let outcomes = lines
.iter()
.map(|line| {
if line.amount_cents == 0 {
tracing::warn!(line.payout_id, "empty statement line dropped");
return HandlerOutcome::drop();
}
HandlerOutcome::ack()
})
.collect();
ready(Err(outcomes))
}
}
A batch is one delivery plus the records librdkafka has already fetched, with no extra waiting, so it is often smaller than the size the mount site names.
The returned list settles the batch element by element; in the manual form Ok(()) acks every element and Err(outcomes) settles each one.
Under Commit::Tracked an element that asks for a retry comes back alone, and the committed position stops in front of it until it settles.
A process that stops first reads the acked elements behind it again: duplicates, not loss.
How much librdkafka keeps queued is a consumer property, set with KafkaTopic::config.
When the queue is full, librdkafka pauses fetching for fetch.queue.backoff.ms (1000 ms by default), which slows large records behind a slow handler.
.config("fetch.queue.backoff.ms", "10") shortens the pause.
The producer side
- Macros
- Manual
#[ruststream::app]
fn app() -> impl App<State = Clients> {
let broker = KafkaBroker::new(["localhost:9092"])
.default_group("payouts")
.producer_config("enable.idempotence", "true")
.flush_timeout(Duration::from_secs(10));
RustStream::new(AppInfo::new("payouts", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(broker, |b| {
b.include(pay_out)
.out_reply(Publish::default().queue_timeout(Duration::from_secs(5)));
b.include(count_view);
b.include(reconcile.batch(nonzero!(500usize)));
})
}
fn app() -> impl App<State = Clients> {
let broker = KafkaBroker::new(["localhost:9092"])
.default_group("payouts")
.producer_config("enable.idempotence", "true")
.flush_timeout(Duration::from_secs(10));
RustStream::new(AppInfo::new("payouts", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(broker, |b| {
b.include(
subscriber("payouts", PayOut)
.map_source(|_| KafkaTopic::new("payouts").commit(Commit::Tracked))
.reply()
.build(),
)
.out_reply(Publish::default().queue_timeout(Duration::from_secs(5)));
b.include(
subscriber("page-views", CountView)
.map_source(|_| KafkaTopic::new("page-views").group("analytics"))
.build(),
);
b.include(
subscriber("statements", Reconcile)
.batch(nonzero!(500usize))
.map_source(|_| {
KafkaTopic::new("statements")
.group("reconciliation")
.commit(Commit::Tracked)
})
.build(),
);
})
}
fn main() -> ExitCode {
run_main(app)
}
At-least-once from end to end needs the producer side too.
enable.idempotence makes librdkafka's own resends of a record land once; producer_config sets it and every other producer property, acks included.
Every publish waits for the cluster's delivery report, so a publish that returned Ok was accepted by Kafka.
A publish waits for space while librdkafka's local queue is full, which slows the handler down to what the cluster takes.
queue_timeout bounds that wait and turns it into an error.
At shutdown the broker flushes the records still in flight, for up to flush_timeout (30 seconds by default).
ClosedKafkaBroker::unflushed_records counts the records that did not make it.
The tests in the example file run the app in process and replace the bank and the analytics store with clients that fail once.
The in-process Kafka commits as a cluster does, so the payout comes back under Commit::Tracked and the page view does not under Commit::Auto.