Skip to content

Subscription partitions with a routing inbox - #936

Merged
DavidBadura merged 57 commits into
4.0.xfrom
subscription-partition-inbox
Oct 16, 2026
Merged

DavidBadura merged 57 commits into
4.0.xfrom
subscription-partition-inbox

Conversation

@DavidBadura

@DavidBadura DavidBadura commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Alternative to #935. Instead of every partition reading the whole stream and resolving the partition key again, the main subscription resolves the key once per message and writes the message index into the inbox of that partition (new table subscriptions_inbox). A partition only processes the indices in its inbox, reads only those messages, and is only claimed when its inbox has work.

This fixes the main problem of #935: if the partitioner changes (or isn't deterministic) while a partition lags behind, #935 silently skips messages. Here a routed message always stays in its partition. It also makes idle partitions cheap, so fine grained keys like one per aggregate become realistic, and a failing partition isolates exactly that key with the existing error/retry handling.

Other differences to #935:

  • no shards, SKIP LOCKED over many small partitions distributes the work already
  • no type column, a partition is a subscription with a partition_key
  • #[Partition] is set on the subscriber class with a partitioner, e.g. #[Partition(new StreamPartitioner())], custom ones implement Partitioner. Built in are StreamPartitioner, TagPartitioner (DCB tags, new TagPartitioner('course') matches course:{id} like #[EventTag(prefix: 'course')]), BucketPartitioner (a fixed number of partitions) and HashedPartitioner. Subscribe methods get the key with #[PartitionKey]
  • # is reserved for the partition ids, #[Subscriber] rejects it in every subscriber id so the two can never collide
  • SubscriptionCriteria::ids matches the subscription id or the subscriber id, so a single partition can be targeted by its id and the console commands pass subscriber ids instead of every partition id

Run and boot claim the main subscriptions first and the partitions with pending messages afterwards, so routed messages are processed in the same run (also with RunSubscriptionEngineRepository). The router looks up the partitions of a run with one query per 500 keys and creates the missing ones at the end of the run. Boot only claims partitions with work and activates the remaining ones once their main subscription finished booting, so it stays linear. Without a limit it repeats the partitions until their inboxes are empty. Main subscriptions are found by their primary key or an index on (partition_key, status), without reading their partitions. A partition loads its inbox messages via a new IndexCriterion, in chunks of 1000 indices because of the parameter limits. The inbox query reads at most limit entries per partition, with a lateral join on PostgreSQL and a union of limited queries on the other databases, so a large backlog doesn't slow down every run.

PartitionedSubscriptionEngineBench uses one partition per aggregate, every aggregate with two events that are as far apart in the stream as possible. PostgreSQL, catch up processes everything from scratch, incremental is one run after 10 new events:

1k aggregates 10k aggregates
catch up, partitioned 0.34s 3.0s
catch up, without partitions 39ms 188ms
incremental, partitioned 11ms 17ms
incremental, without partitions 4.7ms 6.3ms

Partitions are claimed in groups of 100 (partitionsPerClaim), the limit applies to every partition of a group. Normal subscriptions can be grouped too with subscriptionsPerClaim, then they read the stream once. Only subscriptions at most limit apart (1000 without a limit) are grouped. The default stays 1, because a group holds all its locks and spreads worse over several workers. Subscribers with batching are processed one after another within a group, overlapping batches on a shared connection would roll back the wrong one.

By default partitions are kept. With #[Partition(removeIdleAfter: 3600)] the main subscription removes active partitions with an empty inbox that weren't saved for that long, before it routes and while it holds its lock. The next message of the key creates the partition again.

A circuit breaker (PartitionCircuitBreakerSubscriber) stops a partitioned subscriber on a systemic failure: if maxConsecutiveFailures partitions (default 5) fail in a row, the main subscription goes into error and the failed partitions are set back. A partition without retries left only fails and calls #[OnFailed] at the end of the run if the breaker didn't trip, until then nothing about the failure is stored. Whenever the breaker trips these held back failures are dropped, the partitions stay active and try again in the next run. The failed handling moved into an internal FailedMessageProcessor for that. A main subscription in error stops the whole subscriber, also when the routing fails. Listeners can stop a subscription for the rest of a run with the new OnBeforeHandleMessage::$stop, so OnHandleMessage is always followed by a success or an error.

Other details:

  • on MySQL and MariaDB the ids and the partition key use utf8mb4_bin, keys that only differ in case are different partitions
  • partitions emit into the subscription_<subscriber id> stream of their subscriber, it's removed once with the main subscription
  • a partition moves past inbox messages that were removed from the store
  • a new partition never starts in the error status of its main subscription, but in the status before
  • an exception of an OnHandleMessageSuccess listener, like a batch flush, is an error of that one subscription and doesn't fail the whole claimed group
  • detaching a subscriber detaches all its partitions, pause, reactivate and remove handle partitions in groups
  • commands like pause, reactivate, remove, teardown, setup and refresh wait for subscriptions a worker is processing (claim(..., wait: true)) instead of silently leaving them out, processing still uses SKIP LOCKED

The SubscriptionStore interface stays small: claim() takes a list of ids, add(), update() and remove() are variadic. The inbox lives in the optional PartitionInboxStore interface with addToInbox(), inbox(), inboxStats() and removeIdlePartitions(), the store clears the inbox of a partition up to its position on update. A custom store only needs it to support partitions. Loading only the inbox messages works the same way: the built-in loaders implement the optional IndexMessageLoader, a custom loader without it, or an event store without IndexCriterion, falls back to reading the range of the inbox.

This PR raises the minimum PHP version to 8.4 (own commit). find() of the Doctrine store loads error context and cleanup tasks with native lazy ghosts on first access, and the retry listener only claims subscriptions whose retry is due. An idle run with 1000 failed partitions went from out of memory to 13ms.

ConcurrentWorkerTest runs two real worker processes against PostgreSQL, MySQL or MariaDB while new events keep coming in, every message has to be processed exactly once and in order.

SubscriptionStore and SubscriptionEngine get a stats() method that counts the matching subscriptions per status. If the store supports partitions, the stats of the engine also carry how many messages wait in the inboxes and in how many partitions (inboxStats()). The status command uses it for the partitions of a subscriber, e.g. 1204 (error: 2), inbox: 3500 in 120, and only loads them with --partitions.

Known gap: pause, reactivate, remove and refresh still load all partitions of a subscriber at once. The docs recommend keeping their number bounded with BucketPartitioner or removeIdleAfter.

A subscriber with a #[Partition] method is split into one subscription per
partition key. The main subscription resolves the key of every message once
and writes the message index into the inbox of that partition, creating the
partition on the fly. Partitions only process the messages in their inbox and
are only claimed by a worker if the inbox has work, so idle partitions cost
nothing and a changed or non-deterministic key can not lose messages.

Engine command ids now also match the subscriber id, so commands for a
partitioned subscriber apply to all its partitions, and a single partition
can be targeted by its own id.
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Hello 👋

here is the most recent benchmark result:

SplitStreamBench
================

+-------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
|                         | time (kde mode)                                     | memory                                     |
+-------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| subject                 | Tag: <current>     | Tag: base          | time-diff | Tag: <current>  | Tag: base  | memory-diff |
+-------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| benchLoad10000Events () | 1.968ms (±0.00%)   | 1.965ms (±0.00%)   | +0.17%    | 35.775mb        | 36.296mb   | -1.43%      |
| benchSave10000Events () | 327.261ms (±0.00%) | 383.844ms (±0.00%) | -14.74%   | 35.966mb        | 35.966mb   | 0.00%       |
+-------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+

SimpleSetupTaggableStoreBench
=============================

+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
|                                        | time (kde mode)                                     | memory                                     |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| subject                                | Tag: <current>     | Tag: base          | time-diff | Tag: <current>  | Tag: base  | memory-diff |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| benchLoad1Event ()                     | 782.200μs (±0.00%) | 749.300μs (±0.00%) | +4.39%    | 36.313mb        | 36.313mb   | 0.00%       |
| benchLoad10000Events ()                | 41.202ms (±0.00%)  | 42.339ms (±0.00%)  | -2.69%    | 36.313mb        | 36.313mb   | 0.00%       |
| benchSave1Event ()                     | 1.159ms (±0.00%)   | 1.791ms (±0.00%)   | -35.25%   | 36.313mb        | 36.313mb   | 0.00%       |
| benchSave10000Events ()                | 223.808ms (±0.00%) | 290.061ms (±0.00%) | -22.84%   | 36.313mb        | 36.313mb   | 0.00%       |
| benchSave10000Aggregates ()            | 6.036s (±0.00%)    | 5.808s (±0.00%)    | +3.93%    | 36.313mb        | 36.313mb   | 0.00%       |
| benchSave10000AggregatesTransaction () | 3.373s (±0.00%)    | 3.259s (±0.00%)    | +3.48%    | 36.313mb        | 36.313mb   | 0.00%       |
| benchAppend1Event ()                   | 1.211ms (±0.00%)   | 1.121ms (±0.00%)   | +8.09%    | 36.313mb        | 36.313mb   | 0.00%       |
| benchAppend100Events ()                | 4.867ms (±0.00%)   | 5.139ms (±0.00%)   | -5.31%    | 36.313mb        | 36.313mb   | 0.00%       |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+

SubscriptionEngineBench
=======================

+---------------------------+-----------------+-----------------+-----------+-----------------+------------+-------------+
|                           | time (kde mode)                               | memory                                     |
+---------------------------+-----------------+-----------------+-----------+-----------------+------------+-------------+
| subject                   | Tag: <current>  | Tag: base       | time-diff | Tag: <current>  | Tag: base  | memory-diff |
+---------------------------+-----------------+-----------------+-----------+-----------------+------------+-------------+
| benchHandle10000Events () | 1.599s (±0.00%) | 1.558s (±0.00%) | +2.60%    | 48.303mb        | 48.302mb   | +0.00%      |
+---------------------------+-----------------+-----------------+-----------+-----------------+------------+-------------+

NoopSubscriptionEngineBench
===========================

+---------------------------+-------------------+-------------------+-----------+-----------------+------------+-------------+
|                           | time (kde mode)                                   | memory                                     |
+---------------------------+-------------------+-------------------+-----------+-----------------+------------+-------------+
| subject                   | Tag: <current>    | Tag: base         | time-diff | Tag: <current>  | Tag: base  | memory-diff |
+---------------------------+-------------------+-------------------+-----------+-----------------+------------+-------------+
| benchHandle10000Events () | 64.212ms (±0.00%) | 58.474ms (±0.00%) | +9.81%    | 48.303mb        | 48.302mb   | +0.00%      |
+---------------------------+-------------------+-------------------+-----------+-----------------+------------+-------------+

SimpleSetupStreamStoreBench
===========================

+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
|                                        | time (kde mode)                                     | memory                                     |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| subject                                | Tag: <current>     | Tag: base          | time-diff | Tag: <current>  | Tag: base  | memory-diff |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| benchLoad1Event ()                     | 737.200μs (±0.00%) | 729.100μs (±0.00%) | +1.11%    | 35.147mb        | 35.147mb   | 0.00%       |
| benchLoad10000Events ()                | 39.576ms (±0.00%)  | 42.536ms (±0.00%)  | -6.96%    | 35.147mb        | 35.147mb   | 0.00%       |
| benchSave1Event ()                     | 877.400μs (±0.00%) | 880.700μs (±0.00%) | -0.37%    | 35.147mb        | 35.147mb   | 0.00%       |
| benchSave10000Events ()                | 176.262ms (±0.00%) | 191.297ms (±0.00%) | -7.86%    | 35.147mb        | 35.147mb   | 0.00%       |
| benchSave10000Aggregates ()            | 5.273s (±0.00%)    | 6.799s (±0.00%)    | -22.45%   | 35.147mb        | 35.147mb   | 0.00%       |
| benchSave10000AggregatesTransaction () | 3.303s (±0.00%)    | 3.182s (±0.00%)    | +3.80%    | 35.147mb        | 35.147mb   | 0.00%       |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+

SubscriptionEngineBatchBench
============================

+---------------------------+-------------------+-------------------+-----------+-----------------+------------+-------------+
|                           | time (kde mode)                                   | memory                                     |
+---------------------------+-------------------+-------------------+-----------+-----------------+------------+-------------+
| subject                   | Tag: <current>    | Tag: base         | time-diff | Tag: <current>  | Tag: base  | memory-diff |
+---------------------------+-------------------+-------------------+-----------+-----------------+------------+-------------+
| benchHandle10000Events () | 65.800ms (±0.00%) | 62.671ms (±0.00%) | +4.99%    | 35.728mb        | 35.727mb   | +0.00%      |
+---------------------------+-------------------+-------------------+-----------+-----------------+------------+-------------+

PartitionedSubscriptionEngineBench
==================================

+-----------------------------------------------+--------------------+-----------+-----------------+-------------+
|                                               | time (kde mode)                | memory                        |
+-----------------------------------------------+--------------------+-----------+-----------------+-------------+
| subject                                       | Tag: <current>     | time-diff | Tag: <current>  | memory-diff |
+-----------------------------------------------+--------------------+-----------+-----------------+-------------+
| benchCatchUpPartitioned (1000 aggregates)     | 220.362ms (±0.00%) | +0.00%    | 7.837mb         | +0.00%      |
| benchCatchUpPlain (1000 aggregates)           | 27.107ms (±0.00%)  | +0.00%    | 7.837mb         | +0.00%      |
| benchIncrementalPartitioned (1000 aggregates) | 10.877ms (±11.52%) | +27.88%   | 7.837mb         | 0.00%       |
| benchIncrementalPlain (1000 aggregates)       | 3.083ms (±3.72%)   | +9.49%    | 7.837mb         | 0.00%       |
+-----------------------------------------------+--------------------+-----------+-----------------+-------------+

DynamicConsistencyBoundaryBench
===============================

+------------------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
|                                                | time (kde mode)                                     | memory                                     |
+------------------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| subject                                        | Tag: <current>     | Tag: base          | time-diff | Tag: <current>  | Tag: base  | memory-diff |
+------------------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| benchDecisionModelSmall ()                     | 1.133ms (±0.00%)   | 1.171ms (±0.00%)   | -3.19%    | 13.862mb        | 13.862mb   | 0.00%       |
| benchDecisionModelComposite ()                 | 10.018ms (±0.00%)  | 10.537ms (±0.00%)  | -4.92%    | 13.862mb        | 13.862mb   | 0.00%       |
| benchDecisionModel10000Events ()               | 55.485ms (±0.00%)  | 57.359ms (±0.00%)  | -3.27%    | 13.862mb        | 13.862mb   | 0.00%       |
| benchDecisionModel10000EventsWithCheckpoint () | 6.735ms (±0.00%)   | 7.172ms (±0.00%)   | -6.09%    | 13.862mb        | 13.862mb   | 0.00%       |
| benchDecisionModelOnlyLastEvent ()             | 953.800μs (±0.00%) | 980.800μs (±0.00%) | -2.75%    | 13.862mb        | 13.862mb   | 0.00%       |
| benchDecideAndAppend ()                        | 17.592ms (±0.00%)  | 18.689ms (±0.00%)  | -5.87%    | 13.862mb        | 13.862mb   | 0.00%       |
| benchAppendWithCondition ()                    | 2.512ms (±0.00%)   | 2.494ms (±0.00%)   | +0.70%    | 13.862mb        | 13.862mb   | 0.00%       |
+------------------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+

PersonalDataBench
=================

+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
|                                        | time (kde mode)                                     | memory                                     |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| subject                                | Tag: <current>     | Tag: base          | time-diff | Tag: <current>  | Tag: base  | memory-diff |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| benchLoad1Event ()                     | 744.900μs (±0.00%) | 725.400μs (±0.00%) | +2.69%    | 37.009mb        | 37.009mb   | 0.00%       |
| benchLoad10000Events ()                | 99.487ms (±0.00%)  | 97.917ms (±0.00%)  | +1.60%    | 37.009mb        | 37.009mb   | 0.00%       |
| benchSave1Event ()                     | 2.113ms (±0.00%)   | 1.574ms (±0.00%)   | +34.23%   | 37.009mb        | 37.009mb   | 0.00%       |
| benchSave10000Events ()                | 269.635ms (±0.00%) | 254.128ms (±0.00%) | +6.10%    | 37.064mb        | 37.064mb   | -0.00%      |
| benchSave10000Aggregates ()            | 8.330s (±0.00%)    | 8.307s (±0.00%)    | +0.28%    | 37.009mb        | 37.009mb   | 0.00%       |
| benchSave10000AggregatesTransaction () | 6.222s (±0.00%)    | 6.304s (±0.00%)    | -1.29%    | 37.009mb        | 37.009mb   | 0.00%       |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+

SnapshotsBench
==============

+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
|                                        | time (kde mode)                                     | memory                                     |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| subject                                | Tag: <current>     | Tag: base          | time-diff | Tag: <current>  | Tag: base  | memory-diff |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+
| benchLoad10000EventsMissingSnapshot () | 41.517ms (±0.00%)  | 40.852ms (±0.00%)  | +1.63%    | 35.218mb        | 35.218mb   | 0.00%       |
| benchLoad10000Events ()                | 877.400μs (±0.00%) | 979.900μs (±0.00%) | -10.46%   | 35.218mb        | 35.218mb   | 0.00%       |
+----------------------------------------+--------------------+--------------------+-----------+-----------------+------------+-------------+

CommandToQueryBench
===================

+----------------+------------------+------------------+-----------+-----------------+------------+-------------+
|                | time (kde mode)                                 | memory                                     |
+----------------+------------------+------------------+-----------+-----------------+------------+-------------+
| subject        | Tag: <current>   | Tag: base        | time-diff | Tag: <current>  | Tag: base  | memory-diff |
+----------------+------------------+------------------+-----------+-----------------+------------+-------------+
| benchCreate () | 2.934ms (±0.00%) | 2.991ms (±0.00%) | -1.91%    | 5.038mb         | 5.061mb    | -0.44%      |
| benchUpdate () | 4.379ms (±0.00%) | 3.696ms (±0.00%) | +18.49%   | 5.896mb         | 5.757mb    | +2.41%      |
| benchBoth ()   | 7.689ms (±0.00%) | 5.649ms (±0.00%) | +36.12%   | 5.318mb         | 5.213mb    | +2.02%      |
+----------------+------------------+------------------+-----------+-----------------+------------+-------------+

This comment gets update everytime a new commit comes in!

A partition now loads exactly the messages in its inbox through the new
IndexCriterion instead of the whole index range between its first and last
entry, which was as expensive as the stream was long for a key per
aggregate. The run snapshot joins the inbox, so finding pending partitions
costs as much as there is work, not as many partitions as there are.

The router looks up only the partitions of the routed keys instead of
loading all of them, and the detach check only claims main subscriptions,
their partitions follow. Both scanned every partition on every run.

Adds a benchmark with one partition per aggregate.
Run and Boot take subscriptionsPerClaim (default 1) and partitionsPerClaim
(default 100). A group is claimed and processed in one transaction, and
partitions and other subscriptions are never mixed. Grouped subscriptions
read the stream once from the lowest position, a grouped partition reads
only its inbox and gets the full message limit.

The inbox API is batch based now: a whole group is claimed with one
query, its inboxes are read with one query and cleared with one, and
routed messages are inserted with multi row inserts. While catching up,
the router loads all partitions of the subscriber once instead of looking
them up one by one.
The subscription store gets addAll() and updateAll(), and the manager uses
them when a flush has more than one subscription, e.g. a group of
partitions or a router creating new partitions while catching up.

addAll() is a multi row insert. updateAll() sets every column with a
CASE over the id, which is the only form of a batch update that
PostgreSQL, MySQL, MariaDB and SQLite share. PostgreSQL can not infer the
type of the parameters in a CASE, so they are cast there. Since MySQL and
MariaDB only count changed rows, a short affected row count is checked
against the existing ids before a SubscriptionNotFound is thrown.
A batch often keeps a database transaction open from begin to flush. When
several partitions or subscriptions of a group were processed message by
message, their transactions on the shared connection were nested, and the
rollback of a failing batch hit the innermost transaction of another
batch. That batch lost its writes while its position moved on, and the
failing one was committed.

Subscribers with batching now run one after another within a group, the
others are still processed together and the writes of the group are still
flushed at once.
A group reads the stream from its lowest position and the message limit
applies to the whole group. A subscription far behind, e.g. one that is
rebuilt, used up the limit before the others reached their new events, so
they waited until the rebuild had caught up.

Subscriptions are now sorted by position and only grouped while they are
at most the message limit apart (1000 without a limit). Within that gap
there are never more messages than the limit, so every member of a group
is reached in the same run. Without grouping the order of the snapshot is
kept as before.
A partition that failed left the run before its inbox was cleaned up, so
the messages it had processed before the error stayed in the inbox. They
were only removed with the next successful run, and never if the
partition stayed failed. Now they are removed up to its position, which
is the last processed message, or the position before a rolled back
batch.
Subscriptions loaded in bulk will use native lazy objects for their heavy
columns, which need PHP 8.4.
Every run loaded all errored subscriptions with their full error context
just to claim each of them and find out that the retry was not due yet.
With a thousand failed partitions that took more than 128 MB and the
worker died on every run.

The store gets findLazy(), which loads the error context and the cleanup
tasks with native lazy ghosts on first access. Saving such a subscription
reads them, so nothing gets lost. find() stays eager, so the public
subscriptions() and anything comparing or dumping subscriptions is not
affected. The retry listener now checks on that snapshot whether a retry
is due and only claims those subscriptions.
The store had grown batch and lazy variants of most methods. Now there is
one method per operation: claim() takes a list of ids, add(), update() and
remove() take several subscriptions, and find() may load the error context
and the cleanup tasks lazily. The Doctrine store only creates lazy ghosts
when one of them is set, so plain subscriptions stay plain objects.

The inbox moved into the optional PartitionInboxStore interface with just
addToInbox() and inbox(). The store keeps the inbox of a partition to the
messages after its position when the partition is updated or removed, so
the engine no longer clears it separately. A store without it throws
PartitionsNotSupported once a partitioned subscriber is processed.
loadIndices() moved out of MessageLoader into the optional
IndexMessageLoader, which the built-in loaders implement. Without it, or
when the event store does not know the IndexCriterion, a partition reads
the range of its inbox and skips the other messages. A custom message
loader or event store therefore needs nothing new.

The run only filters for pending partitions when the subscription store
has an inbox, so other stores never see that criterion. The router adds
to the inbox before it creates a partition, so a store without an inbox
fails without leaving a partition behind.

Internally, forEachClaimedGroup() takes a ClaimGroups object instead of
three loose numbers, and the retry filter only exists on forEachClaimed().
When the database of a projection is down, every partition with work
failed, used up its retries and ended up failed, and a subscriber with an
OnFailed method even skipped a message in each of them.

A new listener counts the failing partitions of a subscriber during a run.
If #[Partition(maxConsecutiveFailures)] of them (default 5) fail in a row
without a success in between, the main subscription goes into the error
status, the partitions that failed are set back and the others of the run
skip their messages via the new OnHandleMessage::$skip. While the main
subscription is in error, its partitions are not processed at all, so
they continue where they were once it is retried.

Routing no longer resets the retries of the main subscription, otherwise
it would never fail during a long outage. The listener resets them once
partitions succeed again.
A subscribe method can ask for the partition key with a parameter marked
#[PartitionKey], e.g. to write into the database of a tenant, without
computing the key from the message again. Without partitions the value is
null. Only the key is handed out, the subscription itself stays inside the
engine so a handler can not change its state.
A group of partitions loaded the indices of all its inboxes with one IN list. With 100 partitions and a limit of 1000
that are up to 100k parameters, PostgreSQL allows 65535, so the whole group went into error and the circuit breaker
stopped the subscriber. The indices are now loaded in chunks of 1000.
If a routed message was removed from the store before its partition processed it, the partition never loaded it, its
position stayed in front of it and it was claimed on every run. When a partition processed every loaded message, it
now moves to the end of its inbox and logs the skipped range.
Every boot run claimed all booting partitions, also the ones without work, and looked up the main subscription once
per partition. With one partition per aggregate the boot grew quadratically (4000 aggregates: 27k queries, 25s).

Boot now only claims partitions with messages in their inbox, like run does. Once a main subscription finished
booting, its remaining booting partitions are processed one more time to activate them. The runner loads the main
subscriptions of a group with one query. The same boot now needs 1.2k queries and 4s.
The router looked up every new key on its own and after 50 lookups loaded all partitions of the subscriber. The cache
only lives for one run, so with a small message limit and many keys every run loaded every partition.

The router now remembers the partitions it does not know yet and creates the missing ones at the end of the run,
looked up with one query per 500 ids. This also happens when the run fails, so routed messages always have their
partition. Catching up 8000 aggregates with a limit of 100 went from 22s to 11s on PostgreSQL.
The snapshot of a run was taken before the main subscriptions routed, so the partitions only processed the new
messages in the next run. A single run, like the one of RunSubscriptionEngineRepository after a save, left a
partitioned projection behind.

Run and boot now claim the main subscriptions first and find the partitions with pending messages afterwards.
This costs no extra query, the store already looked them up separately. A run is no longer unfinished just because
it routed messages.
If another worker held the main subscription, the breaker could not stop it but still set back the failed
partitions. They started again right away, without backoff and without using up retries. Now the partitions are only
set back if the main subscription is stopped, by this or another worker.

A partition without retries left called its #[OnFailed] method before the breaker decided, which could skip the
message during a systemic failure. That transition is now held back until the end of the run and dropped if the
breaker trips. The failed handling moved from the FailSubscriber into the FailedMessageProcessor, so the breaker can
use it without depending on another listener. The breaker is renamed to PartitionCircuitBreakerSubscriber.
Partition keys come from the messages. The default collations of MySQL and MariaDB treat keys that only differ in
case as equal, so a second partition could not be created and the run failed with SubscriptionAlreadyExists. The id,
subscriber id and partition key columns and the subscription id of the inbox now use utf8mb4_bin there.
…sage

A listener could skip a message with OnHandleMessage::$skip, but the next message that was processed moved the
position past it and the skipped message was lost. A batch even moved to the last index of the run on its flush.

OnHandleMessage::$stop replaces it: the subscription is not processed any further in this run, its position stays in
front of the message and a batch is only flushed up to the last message it handled.
Only active, paused and finished partitions were detached. Booting ones stayed booting and partitions in error kept
being retried although their subscriber was gone.
A custom store that does not know the partitions criteria returns the main subscription itself. The listener removed
it again and dispatched the removed event for it, which ended in an endless recursion.
The inbox query numbered every message of a partition with ROW_NUMBER() and filtered by the limit afterwards, so a
partition with a large backlog was read completely on every run. With a backlog of one million messages one call took
2.7s on MySQL, 1.9s on MariaDB and 148ms on PostgreSQL.

PostgreSQL now uses a lateral join with a limit per partition, the other databases a union of one limited query per
partition, MySQL does not limit a lateral join early enough and MariaDB and SQLite do not support it. One call now
takes 1 to 3ms, no matter how large the backlog is.
Every partition emitted into its own subscription_{subscriber}#{key} stream. Readers of the subscriber stream missed
these events, removing a subscriber deleted one stream per partition, and with a long partition key the stream name
did not fit into the stream column anymore. The emitted stream belongs to the subscriber like setup and cleanup, so
partitions now share it and it is removed once with the main subscription.
Instead of marking a method that returns the partition key, #[Partition] is now set on the subscriber class and takes a
partitioner, e.g. #[Partition(new StreamPartitioner())]. Partitioners are small reusable classes implementing the
Partitioner interface, StreamPartitioner creates one partition per stream. The partitioner is read from the attribute
when it is needed, so it is not part of the cached metadata.
BucketPartitioner spreads the keys of another partitioner over a fixed number of partitions, so the number of
partitions no longer grows with the aggregates. TagPartitioner uses the tag with a prefix as key, for the tags of the
dynamic consistency boundary. HashedPartitioner hashes the key of another partitioner, so it is always short and any
characters are fine.
@DavidBadura
DavidBadura marked this pull request as ready for review October 15, 2026 11:40
The inbox of a partition was cleared with a range delete up to its position. The range delete also locks the next row,
often a message the main subscription has just routed, and MariaDB with snapshot isolation, the default since 11.6,
fails on that row with "Record has changed since last read". The processed rows are now read first and deleted by
their key.
@DavidBadura DavidBadura changed the title POC: subscription partitions with a routing inbox Subscription partitions with a routing inbox Oct 15, 2026
The partitioner was created from the attribute with reflection when it was needed, because a closure could not be
cached with the metadata. Without closures the partitioner is a plain object and is stored in the metadata.
@DavidBadura DavidBadura added the enhancement New feature or request label Oct 15, 2026
@DavidBadura DavidBadura added this to the 4.0.0 milestone Oct 15, 2026
The status command loaded every partition to show a summary, and run and boot loaded them to collect the subscriber
ids. With one partition per aggregate that can be millions of subscriptions. SubscriptionEngineCriteria can now filter
by partitions and status. The status command loads the main subscriptions and only the partitions that need
attention, like the ones in error, all of them only with --partitions. Run and boot only load the main subscriptions.
SubscriptionStore and SubscriptionEngine get a stats() method that counts the subscriptions matching a criteria per
status, without loading them. The status command uses it to show the number of partitions of a subscriber and how many
of them are not active again, it only loads the partitions to list them with --partitions.
Commands like pause, reactivate, remove, teardown, setup and refresh claimed with SKIP LOCKED, so a subscription a
worker was processing at that moment was silently left out, e.g. a partition stayed active after pausing its
subscriber. claim() now takes $wait, these commands and the circuit breaker wait for the lock instead. Detaching and
refreshing no longer update subscriptions without a lock. Processing still skips locked subscriptions.
Since the router creates the partitions at the end of the run, the main subscription can already be in error by then.
The partitions copied its status without an error, so nothing ever processed or retried them and their inbox stayed
unprocessed after the main subscription recovered. A new partition now starts with the status the main subscription
had before its error.
A listener of OnHandleMessageSuccess, like a batch flush, ran outside the error handling of the message processor. Its
exception failed the whole claimed group: every partition of the group went into error, members that had already
failed lost their error and were never retried, and the circuit breaker counted every member. The exception is now
an error of that one subscription, and a failing group no longer overwrites the error of a member.
A partition without retries left was put into error while its failure was held back, and only failed at the end of
the run. If the run did not get there, the partition stayed in error forever without retries left. Now it stays
active, only the position of the messages handled before is stored. If the run does not get to its end, the message
is processed again in the next run. A message another worker processed in the meantime is not failed afterwards.
If the breaker tripped but the main subscription was not running, e.g. paused, it could not be stopped and the held
back failures still ran #[OnFailed], which could skip the messages during a systemic failure. They are now dropped
whenever the breaker trips, the partitions stay active and try again in the next run.
A listener stopped a subscription through OnHandleMessage::$stop, then neither OnHandleMessageSuccess nor
OnHandleMessageError followed. Paired listeners, like one that pushes a message context or opens a span, were left
open. The stop is now decided in the new OnBeforeHandleMessage event, OnHandleMessage is only dispatched if the
message is processed, so it is always followed by a success or an error.
Looking up main subscriptions by id also matched the subscriber id, so every partition of the subscriber was read,
once per partition group, in the circuit breaker and in the run snapshot. Main subscriptions are now found by their
primary key, and an index on the partition key finds them without ids. With one million partitions on PostgreSQL the
run snapshot of the main subscriptions went from 133ms to 0.05ms.
A partition reads at most 1000 messages of its inbox at a time, so a boot without a limit left partitions with a
larger inbox booting, and run never processes booting partitions. Without a limit the boot now repeats the partitions
until their inboxes are processed, like it processes the whole stream for other subscriptions.
The end of boot loaded all booting partitions to check whether there are any, and run looked for active partitions of
finished run once subscribers on every run. Both now count them first and only claim if there are any. The index on
the subscriber id also covers the status, for these counts and for finding idle partitions. The docs mention that
commands like pause or remove load all partitions of a subscriber at once.
new TagPartitioner('course') now matches course:{id} only, the same tags
#[EventTag(prefix: 'course')] creates. Before, 'course' also matched
courseware:7 and an empty prefix matched every tag.
A subscriber with the id foo#x next to the partitioned subscriber foo
made the creation of the partition x fail at flush, again in every run.
The subscriber repository now rejects such ids right away.
Instead of checking the ids of all subscribers against each other in the
repository, #[Subscriber] now rejects any id with #. A subscriber id can
no longer collide with the id of a partition, also with a custom
subscriber repository.
PartitionInboxStore gets inboxStats(), which counts the messages after
the position of the matching partitions and in how many partitions they
wait. The stats of the engine carry it if the store supports partitions,
and the status command shows it next to the number of partitions.
@DavidBadura
DavidBadura merged commit f9854d0 into 4.0.x Oct 16, 2026
32 of 35 checks passed
@DavidBadura
DavidBadura deleted the subscription-partition-inbox branch October 16, 2026 20:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BC-Break enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants