Sequential Processing by Partition Key (sequential_by)
sequential_by gives a handler a per-key ordering guarantee: two different
events that share a partition key are never processed at the same time, and
their committed effects land in publish order. Events with different keys still
run in parallel. This is the escape hatch for the one case the built-in
concurrency primitives (optimistic concurrency, process managers, idempotent
handlers) do not cover — a single high-throughput handler that must serialize by
an entity key without funnelling every key onto one worker.
The full design and its guarantees are in ADR-0028. Before reaching for it, read Designing for Concurrent Event Processing: most concurrency problems have a simpler structural fix.
Publishing side only — events only, inline broker only, today
This page documents the publishing behaviour that ships today: the option, its registration-time validation, and how the outbox routes a partitioned event. The consumer side that actually enforces the ordering across multiple engine instances (partition ownership lease, fencing token, crash reclaim, partition discovery) is tracked separately and is not yet active.
Commands do not currently go through the outbox at all — a command handler
dispatches synchronously and never produces an outbox row, so it has no
stream_category and nothing to partition. Declaring sequential_by on a
command handler is accepted and checked at registration (field
existence, one key per category), but that check is inert today: it has no
runtime effect until command publishing exists. Everything below about
extraction, validation, and routing describes events only.
No broker shipped with Protean advertises the STREAM_PARTITIONING
capability yet, so today sequential_by is usable only on the inline
broker, where it is a validated no-op (inline already processes messages in
submission order). On any other broker (for example Redis) declaring
sequential_by fails domain.init() with an IncorrectUsageError — the
broker-capability check below rejects it — and it stays that way until the
consumer side lands and a broker turns the capability on. The routing and
Redis-specific guidance below describe the target behaviour for that future
broker, not something you can switch on now.
Declaring the key
On an event handler or command handler, sequential_by is the name of a
direct field on the event or command payload. Only the event-handler case has a
runtime effect today — see the warning above.
@domain.event_handler(part_of=Order, sequential_by="client_id")
class OrderProjector(BaseEventHandler):
@handle(OrderPlaced)
def on_placed(self, event: OrderPlaced) -> None:
...
On a process manager it is a boolean opt-in. A process manager already
declares its serialization domain through its correlate specs, so
sequential_by=True partitions each subscribed category by the field that
category's correlate maps to the correlation value:
@domain.process_manager(sequential_by=True)
class OrderFulfillment(BaseProcessManager):
order_id = Identifier(identifier=True)
@handle(OrderPlaced, start=True, correlate="order_id")
def on_placed(self, event: OrderPlaced) -> None:
...
Nested paths and computed keys are out of scope: the key is a single named field.
What is validated at registration
domain.init() fails loud if any of these do not hold, turning a class of
runtime surprises into a startup error:
- Field existence. Every event or command type the handler handles must
declare the named field. For a process manager, every handled event must carry
the field its
correlatespec resolves for that event. - One key per category. A partition key is a property of the stream category, not of one handler. Two handlers on the same category that ask for different keys are rejected — all handlers on a category must agree on one key or not partition it at all.
- Broker capability. The target broker must advertise the
STREAM_PARTITIONINGcapability. The inline broker is the accepted exception (it is a no-op there); any other broker without the capability is rejected.
Key values that are rejected
The partition key becomes the {stream_category}:{key} stream-name segment, so
a value that would collide with reserved stream names is rejected. For an
event, the value is extracted and validated synchronously in the caller's
transaction when the outbox record is created, so a bad value fails the
caller's operation immediately and never enters the outbox. A key is rejected
when it is:
- null or empty,
- contains a colon (
:), - equals a reserved lane/DLQ token (
dlq, or the configured[server.priority_lanes]backfill_suffix, defaultbackfill), or - matches the reserved
__name__sentinel form (double-underscore delimited, e.g.__partitions__).
Real partition keys are entity identifiers (a client_id, an order_id, a
UUID), which never hit these rules. Rejecting rather than routing a bad key to a
default partition is deliberate: a default partition would silently coalesce
unrelated keys and break ordering for all of them.
How a partitioned event is routed
For a partitioned category the outbox stores the validated key on the row's
partition_key field. When the target broker advertises STREAM_PARTITIONING,
the outbox processor routes the row to {stream_category}:{key}, composing with
the priority-lane suffix when a low-priority row also uses the backfill lane:
- partition stream:
{stream_category}:{key}(for exampleorder:client-123) - partition backfill lane:
{stream_category}:{key}:{backfill_suffix}
Under a broker without the capability the key is carried on the row but not
applied, so routing falls back to the base category. As a safety backstop, a row
that somehow reaches publishing with an invalid key is marked ABANDONED at once
rather than retried, so one bad row can never wedge the outbox behind it.
Applicability
Partition-per-key on Redis is bounded-cardinality by design. The key must be a natural serialization domain with a bounded live set (a tenant, a region, an account), not a high-cardinality per-entity key (one partition per order or per request in a large population). See ADR-0028's "Applicability and limits" for the reasoning and the ceiling.