feat: batched outbox forwarding for combined message channels (Enterprise) - #692
Merged
Conversation
…nBlockingDelivery
… delivers all messages in order
…ery, connection failures abort cycle for transactional retry
…lector protection for handler-published messages
…elay benchmark Kafka module requires an Ecotone Enterprise licence unconditionally, unlike AMQP where only high-throughput publishing needs one. setUpKafkaRelayMessageByMessage passed licenceKey: null, causing a LicensingException fatal error in CI.
…batch size on CombinedMessageChannel, skip draining non-auto-acked sources
…e collector for relay targets at configuration time
dgafka
commented
Aug 4, 2026
dgafka
commented
Aug 4, 2026
dgafka
commented
Aug 4, 2026
dgafka
commented
Aug 4, 2026
dgafka
commented
Aug 4, 2026
…d channel outbox relays Batch forwarding no longer consumes the outbox through a message channel consumer. An opt-in BatchForwardingConfiguration replaces the channel consumer with a polling endpoint executing SQL directly per tick: claim up to batch size rows (FOR UPDATE SKIP LOCKED on PostgreSQL, claim markers elsewhere), group by routing slip target, publish groups, delete delivered and release failed rows inside an explicit per channel transaction. Rows relay in wire format without deserialization. Multiple outbox channels can share one publishing process via withEndpointId, covering outboxes living in different databases. Unconsumed configurations, execution channel and output channel usage of the outbox fail at compile time. Warmed benchmark relays 10k messages in ~1s into a durable Dbal target against ~82s message by message.
…llable channels Batch entries whose payload is already a string carrying own message id and timestamp skip the intermediate Message construction on the outbound path, falling back to full preparation otherwise. Header mapping short-circuits scalar values and the match-all mapping before entering type analysis. The Dbal batch insert binds parameters with direct ParameterType bindings and inlines constant columns, bypassing the doctrine type registry per value.
… release line Release 1.322.2 bumped the path repository branch alias, making the ~1.320.0 pin unresolvable against the canonical path repo and failing Split Testing for every branch created since. All sibling packages already pin ~1.322.2.
…r-run delivery assertions Failure strategy is configured on BatchForwardingConfiguration itself and inherits from the outbox channel when not set. Relay tests assert exact payload routing per run and inspect the backing store for release flags, claim restoration and remaining rows instead of counting received messages.
Each publisher run drains the next tenant outbox in round robin, reusing the polling consumer tenant propagation, so target sends route to the drained tenant. The batch publishing endpoint carries WithoutDatabaseTransaction, now honoured consistently by the object manager and deduplication interceptors alongside the transaction interceptor, which also removes the per tick deduplication insert every relay was paying.
Provider subjects reuse the end-to-end warm up, so queue and topic creation stays outside the measured drain. Broker targets receive whole batches via high throughput publishing.
…utbox relay The Dbal owned channel type replaces BatchForwardingConfiguration: it extends CombinedMessageChannel with exactly one Dbal backed source and one target, carries batch size, endpoint id and failure strategy, and may embed the source channel builder itself so the outbox cannot be misconfigured by name. Messaging core keeps only the OutboxForwardingChannel contract, unwraps embedded source builders into extension objects and guards at compile time that the source is claimed by a forwarding module and not reused inside plain Combined Message Channels. Forwarding channels sharing one outbox must agree on their settings.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why is this change proposed?
Why
An outbox in front of a broker channel relays one message per poll cycle, so a single consumer run barely moves a busy queue, and a deep backlog makes each cycle progressively slower. This change turns the outbox into a true relay: an opt-in publishing process drains messages in batches straight from the database and pushes them forward in bulk, without consuming them through a message channel or deserializing payloads. Relaying 10 000 messages into a durable Dbal target drops from ~82s to ~1s (and to ~0.35s as a single in-memory-bound batch). Delivery stays at-least-once: a failed delivery is released for redelivery without duplicating already-delivered messages, and connection failures roll the whole cycle back for a clean retry.
Resulting flow
graph TD A[Polling tick of batch publishing endpoint] --> B[Claim up to batch size rows per outbox channel<br/>FOR UPDATE SKIP LOCKED on PostgreSQL, claim markers elsewhere] B --> C[Group rows by routing slip target, payloads stay serialized] C --> D{Target supports<br/>high-throughput publishing?} D -- yes --> E[Single BatchMessage per target, one multi-row insert] D -- no --> F[Per-message publish] E --> G[Delete delivered rows, commit per-channel transaction] F --> G G --> H{Delivery failed?} H -- release/ignore strategy --> I[Release only failed rows for redelivery, or drop on ignore] H -- stop or connection failure --> J[Rollback restores claims instantly, clean retry]Out of scope
Batch forwarding for non-Dbal outbox sources — configuring it for any other channel fails at compile time.
Example
OutboxForwardingMessageChannelextendsCombinedMessageChanneland is the whole definition of the relay: exactly one Dbal backed source (the builder may be embedded, registering the outbox channel along the way and making a non database source unrepresentable) and one target channel. Reusing the outbox inside a plainCombinedMessageChannel, pointing an endpoint or output channel at it, or configuring it without a Dbal source all fail at compile time; forwarding channels sharing one outbox must agree on batch size, endpoint id and failure strategy.One process can relay multiple outboxes — including ones in different databases — by sharing an endpoint id:
OutboxForwardingMessageChannel::create('orders', 'ordersOutbox', 'ordersTarget')->withEndpointId('outboxPublisher')for each flow, then running theoutboxPublisherconsumer.With multi-tenancy each tenant already owns an outbox in its own database; the publisher reuses polling consumer tenant propagation, so every run drains the next tenant's outbox in round robin and forwards to that tenant's target — no extra configuration beyond the standard
MultiTenantConfiguration.Failure handling per strategy
The strategy applied to failed deliveries is configured on the forwarding itself; when not set, it inherits from the outbox channel (default
RESEND):RESEND(default) /RELEASEIGNORESTOPThe error channel is not involved in relay failures — the outbox itself is the retry store, so failed messages never leave the delivery guarantee for a dead-end.
Benchmark results
Draining a 10 000 message outbox (PostgreSQL source, warmed consumer, phpbench mode; all broker targets receive whole batches via high-throughput publishing):
Every broker receives 10 000 durable messages in under a second except SQS, which is bound by its API's 10-message batch limit (~1000 HTTP round trips). The message-by-message path also degrades as the backlog deepens (per-message cost grew from ~2.4 ms at 200 messages to ~7.7 ms at 10 000), while the batched path improves with volume as fixed per-run overhead amortizes. Batch size is the throughput knob: one large batch outperforms a hundred 100-row cycles at the cost of holding the whole batch in memory within one transaction.
Description of Changes
OutboxForwardingMessageChannel(Enterprise) — a Combined Message Channel of exactly one Dbal backed outbox and one target — replaces the outbox channel's consumer with a standalone polling endpoint executing SQL directly: claim, group by routing-slip target, publish, delete/release — all inside an explicit per-channel transaction; rows relay in wire format, so serialization happens once on the producing side and format conversion belongs to the target channelwithFinalFailureStrategyon the forwarding configuration, inheriting from the outbox channel when not set): release only failed rows, ignore drops them, stop and connection failures roll back the transaction so claims restore instantly; crashed processes' claims are swept for redeliverywithAsyncPublishing()renamed towithHighThroughputPublishing()across DBAL, AMQP, Kafka, Redis and SQS channel builders;finalFailureStrategyis now correctly passed to DBAL, SQS and Redis inbound message converters (pre-existing gap, AMQP already did)WithoutDatabaseTransaction, now honoured consistently by the transaction, object manager and deduplication interceptors (also removing a per-tick deduplication insert every relay paid)Pull Request Contribution Terms