Skip to content

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=True on 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.