Exactly-Once Delivery¶
Exactly-once delivery aims to eliminate duplicate processing for a subscription.
In FastPubSub, it is configured through enable_exactly_once_delivery=True on the subscriber.
This capability should be treated as a domain-level decision, not a default optimization. It introduces additional coordination cost and changes throughput characteristics.
Delivery Semantics in Context¶
| Model | Guarantee | Typical Usage |
|---|---|---|
| At-least-once | Message may be delivered multiple times | Most event systems with idempotent handlers |
| Exactly-once | Message is processed without duplicate delivery | Duplicate-sensitive operations |
By default, Pub/Sub uses at-least-once delivery. That default is usually sufficient when handlers are idempotent.
Enabling Exactly-Once¶
@broker.subscriber(
alias="payment-processor",
topic_name="payments",
subscription_name="payments-subscription",
enable_exactly_once_delivery=True,
autocreate=True,
)
async def process_payment(message: Message):
# Guaranteed to run exactly once per message
await charge_customer(message.data)
The configuration is explicit and local to the subscriber. You can apply it selectively to critical flows rather than globally.
Decision Framework¶
Enable exactly-once when all of the following are true:
- Duplicate side effects are expensive or unacceptable.
- Handler idempotency is hard to guarantee for business reasons.
- Added latency and cost are acceptable for that workflow.
Avoid enabling exactly-once when:
- Handlers are naturally idempotent.
- Throughput is more important than strict single-processing semantics.
- The flow is observational (analytics, telemetry) and duplicates are tolerable.
Engineering Trade-offs¶
| Dimension | At-Least-Once | Exactly-Once |
|---|---|---|
| Throughput | Higher | Lower |
| Latency | Lower | Higher |
| Cost | Lower | Higher |
| Duplicate protection | Application concern | Broker-level guarantee |
Exactly-Once Is Not a Universal Default
Use it for operations where duplicate effects are materially harmful. For general event processing, idempotent handlers are often a better long-term baseline.
Idempotency as a Robust Alternative¶
FastPubSub supports straightforward idempotent designs without enabling exactly-once.
@broker.subscriber(
alias="idempotent-handler",
topic_name="events",
subscription_name="events-subscription",
# No exactly-once needed - handler is idempotent
)
async def idempotent_handler(message: Message):
event_id = message.attributes.get("event_id")
if not event_id:
logger.warning("Missing event_id attribute, skipping message")
return
# Check if already processed
if await is_already_processed(event_id):
logger.info(f"Event {event_id} already processed, skipping")
return
await process_event(message.data)
await mark_as_processed(event_id)
Database Constraint Pattern¶
@broker.subscriber(
alias="db-idempotent",
topic_name="db-orders",
subscription_name="db-orders-subscription",
)
async def db_idempotent_handler(message: Message):
import json
order_id = json.loads(message.data)["order_id"]
# Use database transaction with unique constraint
try:
await db.execute(
"INSERT INTO processed_orders (order_id) VALUES (?)", [order_id]
)
except UniqueViolationError:
return # Already processed
await fulfill_order(message.data)
Cache/Key Store Pattern¶
@broker.subscriber(
alias="redis-idempotent",
topic_name="redis-events",
subscription_name="redis-events-subscription",
)
async def redis_idempotent_handler(message: Message):
event_id = message.attributes.get("event_id")
if not event_id:
return
# Set with NX (only if not exists), expire after 24 hours
was_set = await redis.set(f"processed:{event_id}", "1", nx=True, ex=86400)
if not was_set:
return # Already processed
await process_event(message.data)
These patterns usually remain valid across brokers and replay workflows.
Composition with Other Reliability Controls¶
Critical subscriptions normally combine delivery guarantees with dead-letter and retry policy:
@broker.subscriber(
alias="critical-payment",
topic_name="critical-payments",
subscription_name="critical-payments-subscription",
# Delivery guarantee
enable_exactly_once_delivery=True,
# Error handling
dead_letter_topic="payments-dlq",
max_delivery_attempts=5,
# Backoff for transient failures
min_backoff_delay_secs=10,
max_backoff_delay_secs=300,
autocreate=True,
)
async def process_critical_payment(message: Message):
await charge_customer(message.data)
This prevents a strict delivery model from masking unresolved failure classes.
Validation with PubSubTestClient¶
For local validation, test idempotency behavior directly in the handler path.
async def test_idempotent_handler_skips_duplicates() -> None:
test_broker = PubSubBroker(project_id="test-project")
processed: list[str] = []
@test_broker.subscriber(
alias="orders",
topic_name="orders",
subscription_name="orders-subscription",
)
async def handle(message: Message) -> None:
event_id = message.attributes.get("event_id", "")
if event_id in processed:
return
processed.append(event_id)
async with PubSubTestClient(test_broker) as client:
await client.publish(
topic="orders",
data={"order_id": "ord-1"},
attributes={"event_id": "e-1"},
)
await client.publish(
topic="orders",
data={"order_id": "ord-1"},
attributes={"event_id": "e-1"},
)
assert processed == ["e-1"]
PubSubTestClient does not emulate full managed delivery internals.
It is best used to verify deterministic application behavior when duplicate inputs occur.
Design Recommendations¶
Keep Idempotency Keys Even with Exactly-Once¶
Include explicit identifiers such as event_id in message attributes or payload.
They support diagnostics, replay logic, and broker migration scenarios.
Exactly-once behavior in managed Pub/Sub has platform constraints and should be validated against current Google guidance.
For details, see Google Pub/Sub: Exactly-Once Delivery.
Measure Before and After Enabling¶
Track latency percentiles and throughput for the specific subscription. Exactly-once should be justified by measurable risk reduction.
Apply Selectively¶
Use per-subscriber granularity:
- Enable on billing, payment, and irreversible state transitions.
- Keep at-least-once for high-volume, idempotent, or analytical streams.
Recap¶
- Exactly-once is a targeted reliability feature for duplicate-sensitive domains.
- Enable it with
enable_exactly_once_delivery=Trueon the subscriber. - Expect latency and cost trade-offs.
- Prefer explicit idempotency where practical and portable.
- Validate handler behavior early with
PubSubTestClient; validate managed semantics in integration.