You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
We independently proved and reproduced the ACK temporal collision in Apache Pulsar Shared subscription — a race condition where a consumer crashes (or fails to ack in time) after storing a result but before the acknowledgment reaches the broker, causing the redelivered message to be processed twice.
This is documented behavior (Pulsar at-least-once delivery with unacked_messages_timeout_ms), not an implementation bug. Our contribution is:
Formal proof via TLA+ model checking — exhaustively explores all interleavings including batch delivery semantics
Chaos cross-validation via ack timeout + PostgreSQL — confirms the mathematical prediction in a physical containerized environment
Cross-broker reproduction — identical scenario reproduced on RabbitMQ, NATS JetStream, and Kafka, proving it is a fundamental distributed systems problem
Pulsar Shared subscription differs from Kafka (offset commit) and NATS (AckWait) in two important ways:
Batch delivery: Pulsar delivers messages in batches. The crash window is between Process (individual message within a batch) and SendAck (batch-level acknowledgment). If the consumer crashes after processing one message but before acknowledging the batch, the entire batch is redelivered — including already-acked messages that lose their ack state.
Ack timeout, not docker kill: Pulsar does not redeliver messages on connection drop in Shared subscription (the broker waits for consumer reconnection). Redelivery is triggered by unacked_messages_timeout_ms — a consumer-side timeout that must be shorter than the processing delay. We used ACK_TIMEOUT_MS=11000 with PROCESSING_TIME_MS=15000.
Formal Proof (TLA+)
We modeled the system as a TLA+ specification with the following actions:
DeliverBatch — broker delivers a batch of messages
Process — consumer processes one message and stores the result
AckTimeout — broker detects unacked messages after timeout
RedeliverBatch — broker redelivers the unacked batch
The recommended consumer-side fix is the idempotent consumer pattern:
consumer=client.subscribe(
topic='persistent://public/default/test-topic',
subscription_name='test-sub',
subscription_type=SubscriptionType.Shared,
unacked_messages_timeout_ms=11000, # must be < processing time
)
whileTrue:
msg=consumer.receive()
# Idempotent guard: check before processingifshould_skip(int(msg.data().decode())):
consumer.acknowledge(msg)
continuestore_result(int(msg.data().decode())) # INSERT into DBtime.sleep(15000) # simulate work (must be > ack timeout)consumer.acknowledge(msg)
The key insight: the unacked_messages_timeout_ms must be shorter than the processing delay (Pulsar ignores values ≤ 10s), so redelivery happens while the consumer is still alive.
Discussion
We would love feedback from the Pulsar team and community:
Are there any Pulsar-specific configurations (e.g., ackTimeoutMillis, acknowledgementGroupTime) that could narrow the crash window at the broker level?
Does Pulsar's Key_Shared subscription eliminate this class of collisions, or does the same fundamental issue apply?
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Summary
We independently proved and reproduced the ACK temporal collision in Apache Pulsar Shared subscription — a race condition where a consumer crashes (or fails to ack in time) after storing a result but before the acknowledgment reaches the broker, causing the redelivered message to be processed twice.
This is documented behavior (Pulsar at-least-once delivery with
unacked_messages_timeout_ms), not an implementation bug. Our contribution is:The Scenario
Pulsar-Specific Details
Pulsar Shared subscription differs from Kafka (offset commit) and NATS (AckWait) in two important ways:
Process(individual message within a batch) andSendAck(batch-level acknowledgment). If the consumer crashes after processing one message but before acknowledging the batch, the entire batch is redelivered — including already-acked messages that lose their ack state.unacked_messages_timeout_ms— a consumer-side timeout that must be shorter than the processing delay. We usedACK_TIMEOUT_MS=11000withPROCESSING_TIME_MS=15000.Formal Proof (TLA+)
We modeled the system as a TLA+ specification with the following actions:
DeliverBatch— broker delivers a batch of messagesProcess— consumer processes one message and stores the resultAckTimeout— broker detects unacked messages after timeoutRedeliverBatch— broker redelivers the unacked batchSkipRedeliver— idempotent guard detects already-executed messagesResults:
InvariantNoDoubleExecutionfailsdb[id]==0guard)Chaos Cross-Validation
We built a pilot (
pilot_pulsar/) that:unacked_messages_timeout_ms=11000andPROCESSING_TIME_MS=15000UnAckedMessageTrackerfires — redelivers the messageResults:
Cross-Broker Validation
We adapted the same TLA+ model and chaos pilot for RabbitMQ, NATS JetStream, and Kafka. All results match:
Verdict: 8/8 TLC predictions matched 8/8 chaos experiments across 4 brokers.
The Fix
The recommended consumer-side fix is the idempotent consumer pattern:
The key insight: the
unacked_messages_timeout_msmust be shorter than the processing delay (Pulsar ignores values ≤ 10s), so redelivery happens while the consumer is still alive.Discussion
We would love feedback from the Pulsar team and community:
ackTimeoutMillis,acknowledgementGroupTime) that could narrow the crash window at the broker level?Beta Was this translation helpful? Give feedback.
All reactions