iii
Architecture

Queue System

How the Named Queues system is designed and why it works the way it does.

This document explains the design decisions behind the Named Queues system introduced in iii. It focuses on the reasoning behind architectural choices, not on how to use the system. For how-to guidance, see Use Queues and Manage Failed Triggers.

Why Named Queues replaced topic-based pub/sub

The original queue model was a pub/sub system: a producer called trigger({ function_id: 'enqueue', payload: { topic: 'order.created', data }, action: TriggerAction.Void() }), and a consumer registered with registerTrigger({ type: 'queue', function_id: '...', config: { topic: 'order.created' } }). This meant:

  • The producer had to know the topic name, which was a string shared by convention — no structural link between producer and consumer.
  • Adding a consumer required modifying the consuming worker's code to call registerTrigger.
  • Queue settings (retries, concurrency) lived at the trigger registration site, spreading infrastructure concerns across every worker that consumed a topic.

Named Queues flip this model. The producer calls trigger() targeting the specific function it wants to invoke, with a queue name as the delivery mechanism. The consumer is just a normal function — it receives data as its input and does not know or care that it arrived via a queue.

This has two consequences. First, the producer-to-consumer relationship is explicit: a trigger() call names both the destination function and the queue to route through. Second, queue configuration becomes a single-source-of-truth concern in iii-config.yaml rather than being scattered across worker registrations.

Why the engine owns the consumer loop

In the Named Queues design, transport adapters (Builtin, RabbitMQ, Redis) are responsible only for message storage and delivery. The engine itself runs the consumer loop and controls:

  • Concurrency: a semaphore per queue enforces the concurrency limit regardless of adapter.
  • Retry logic: the engine decides when and whether to retry, applying the backoff_ms and max_retries from the queue's config.
  • Acknowledgement timing: the engine acks a message only after the function handler returns successfully.

The consequence is behavioral uniformity: the same retry semantics, the same concurrency model, and the same DLQ routing apply whether you are using the Builtin adapter locally or RabbitMQ in production. Switching adapters does not change the behavior your functions experience.

Why TriggerAction exists

Before Named Queues, the SDK had three separate methods for different routing modes: trigger() for synchronous calls, triggerVoid() for fire-and-forget, and triggerVoid('enqueue', ...) as a special convention for queue delivery. Each was a different API surface that produced different behaviors. These have been unified into a single trigger() with TriggerAction.

TriggerAction unifies these into a single trigger() call with an optional action parameter. The three routing modes map cleanly:

  • No action: synchronous request/response.
  • TriggerAction.Enqueue({ queue }): async delivery via a named queue.
  • TriggerAction.Void(): fire-and-forget, no queue.

This means callers can change the routing mode of an invocation without changing which function they call or what payload they send — only the action changes. It also makes routing intent explicit and readable in code, rather than implied by which method was used.

Why Enqueue returns an acknowledgement but Void does not

When a caller uses TriggerAction.Enqueue, the engine must look up the named queue in config, validate that it exists, and — for FIFO queues — verify that the message_group_field is present and non-null in the payload. These are synchronous checks that can fail, and the caller needs to know if they did. The messageReceiptId in the returned EnqueueResult is the UUID the engine assigns to the job, stamped as the AMQP message_id on RabbitMQ. It allows the caller to correlate a specific enqueue with a later DLQ entry or retry event.

TriggerAction.Void is true fire-and-forget: the engine forwards the invocation and immediately returns. There is no job to track, no queue to validate, and no failure case that the caller needs to act on. Returning a receipt would imply a guarantee the system does not make.

Why the RabbitMQ topology was redesigned

The previous RabbitMQ integration used the dead-letter exchange (DLX) as the retry mechanism: a failed message was nacked and RabbitMQ automatically routed it through the DLX to a retry queue, from which it was eventually redelivered to the main queue.

This approach had a structural problem. RabbitMQ's x-delivery-count header — which the previous design relied on to count retry attempts — is only available on quorum queues. Classic queues do not track delivery count. This meant the retry behavior silently differed depending on the queue type configured in the RabbitMQ cluster.

The new design makes the DLX native to the DLQ path rather than the retry path:

  • The main queue's DLX points to a DLQ exchange. When a message is exhausted, it is nacked and RabbitMQ routes it directly to the DLQ — no engine code required.
  • Retry is explicit: on a recoverable failure, the engine acks the message (removing it from the main queue) and republishes it to a retry exchange with a custom x-attempt header that increments on each attempt.

The x-attempt header is set by the engine, not by RabbitMQ. It works identically on classic and quorum queues because it is just a message header that the engine reads and writes. The queue type no longer affects retry counting.

Adapter behavior summary

For a side-by-side comparison of retry, DLQ, and FIFO support across adapters, see the Queue module reference.

On this page