Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6b30acd
refactor: rename channel builder withAsyncPublishing to withBatchedNo…
dgafka Aug 4, 2026
db81ef8
feat: batched drain-and-group forwarding for combined channel relays …
dgafka Aug 4, 2026
d765c17
feat: configurable max forwarding batch size on message channel builders
dgafka Aug 4, 2026
f4f5597
test: batch forwarding into target with batched non-blocking delivery…
dgafka Aug 4, 2026
e412e5d
test: non-enterprise relay keeps one message per consumer run
dgafka Aug 4, 2026
8e0587d
test: shared outbox groups drained messages per routing slip target
dgafka Aug 4, 2026
ec532b8
test: failed forwarding keeps all drained messages available on outbox
dgafka Aug 4, 2026
80d6269
feat: benchmark outbox relay batched vs message-by-message for AMQP a…
dgafka Aug 4, 2026
a4565d9
feat: per-message delivery failure isolation with release for redeliv…
dgafka Aug 4, 2026
e2dc0b9
fix: relay forwards bypass message collector per message, keeping col…
dgafka Aug 4, 2026
7f1c6db
refactor: rename missed kafka channel builder toggle call site
dgafka Aug 4, 2026
aa2b6cd
refactor: rename channel builder toggle to withHighThroughputPublishing
dgafka Aug 4, 2026
c21551e
fix: use enterprise licence key for Kafka message-by-message outbox r…
dgafka Aug 4, 2026
40d3d09
refactor: confine batched forwarding to combined channel relays with …
dgafka Aug 4, 2026
ebddf02
fix: pin kafka relay baseline benchmark to batch size one under enter…
dgafka Aug 4, 2026
5b9b6ff
fix: no-wait drain receive and final failure strategy honoured for dr…
dgafka Aug 4, 2026
ac9cdfd
fix: gate drain sources to marker-capable channel builders and disabl…
dgafka Aug 4, 2026
5adabdd
refactor: standalone direct-SQL batch publishing endpoint for combine…
dgafka Aug 5, 2026
9ac7b73
perf: wire-format batch entries and low-overhead batch inserts for po…
dgafka Aug 5, 2026
c87bf8f
fix: bump stale pdo-event-sourcing dev pin in JmsConverter to current…
dgafka Aug 5, 2026
019851a
test: consumer execution limit maps one handled execution to one forw…
dgafka Aug 5, 2026
b24b327
feat: configurable final failure strategy on batch forwarding with pe…
dgafka Aug 5, 2026
0da4f2b
feat: round-robin multi-tenant outbox publishing
dgafka Aug 5, 2026
a35ab1f
bench: outbox relay draining into RabbitMQ, Kafka, Redis and SQS targets
dgafka Aug 6, 2026
c674154
feat: OutboxForwardingMessageChannel as the single definition of an o…
dgafka Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Monorepo/Benchmark/AsyncPublishingBenchmark.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public function setUpAmqpBatchChannel(): void
{
$this->batchChannel = $this->bootstrapBatchChannel(
ModulePackageList::AMQP_PACKAGE,
AmqpBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withAsyncPublishing(),
AmqpBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withHighThroughputPublishing(),
[AmqpConnectionFactory::class => new AmqpConnectionFactory(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f'])],
);
$this->warmUpBatchChannel();
Expand All @@ -95,7 +95,7 @@ public function setUpKafkaBatchChannel(): void
$uniqueId = uniqid('benchmark_orders_');
$this->batchChannel = $this->bootstrapBatchChannel(
ModulePackageList::KAFKA_PACKAGE,
KafkaMessageChannelBuilder::create($uniqueId, topicName: $uniqueId, messageGroupId: $uniqueId)->withAsyncPublishing(),
KafkaMessageChannelBuilder::create($uniqueId, topicName: $uniqueId, messageGroupId: $uniqueId)->withHighThroughputPublishing(),
[KafkaBrokerConfiguration::class => KafkaBrokerConfiguration::createWithDefaults([getenv('KAFKA_DSN') ?: 'localhost:9094'])],
);
$this->warmUpBatchChannel();
Expand All @@ -111,7 +111,7 @@ public function setUpDbalBatchChannel(): void
{
$this->batchChannel = $this->bootstrapBatchChannel(
ModulePackageList::DBAL_PACKAGE,
DbalBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withAsyncPublishing(),
DbalBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withHighThroughputPublishing(),
[DbalConnectionFactory::class => new DbalConnectionFactory(getenv('DATABASE_DSN') ?: 'pgsql://ecotone:secret@localhost:5432/ecotone')],
);
$this->warmUpBatchChannel();
Expand All @@ -127,7 +127,7 @@ public function setUpRedisBatchChannel(): void
{
$this->batchChannel = $this->bootstrapBatchChannel(
ModulePackageList::REDIS_PACKAGE,
RedisBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withAsyncPublishing(),
RedisBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withHighThroughputPublishing(),
[RedisConnectionFactory::class => new RedisConnectionFactory(getenv('REDIS_DSN') ?: 'redis://localhost:6379')],
);
$this->warmUpBatchChannel();
Expand All @@ -149,7 +149,7 @@ public function setUpSqsBatchChannel(): void
{
$this->batchChannel = $this->bootstrapBatchChannel(
ModulePackageList::SQS_PACKAGE,
SqsBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withAsyncPublishing(),
SqsBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withHighThroughputPublishing(),
[SqsConnectionFactory::class => new SqsConnectionFactory(getenv('SQS_DSN') ?: 'sqs:?key=key&secret=secret&region=us-east-1&endpoint=http://localhost:4566&version=latest')],
);
$this->warmUpBatchChannel();
Expand Down
248 changes: 248 additions & 0 deletions Monorepo/Benchmark/OutboxRelayBenchmark.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
<?php

declare(strict_types=1);

namespace Monorepo\Benchmark;

use Ecotone\Amqp\AmqpBackedMessageChannelBuilder;
use Ecotone\Dbal\DbalBackedMessageChannelBuilder;
use Ecotone\Dbal\OutboxForwardingMessageChannel;
use Ecotone\Kafka\Channel\KafkaMessageChannelBuilder;
use Ecotone\Kafka\Configuration\KafkaBrokerConfiguration;
use Ecotone\Lite\EcotoneLite;
use Ecotone\Lite\Test\FlowTestSupport;
use Ecotone\Messaging\Attribute\Asynchronous;
use Ecotone\Messaging\Channel\CombinedMessageChannel;
use Ecotone\Messaging\Channel\SimpleMessageChannelBuilder;
use Ecotone\Messaging\Config\ModulePackageList;
use Ecotone\Messaging\Config\ServiceConfiguration;
use Ecotone\Messaging\Endpoint\ExecutionPollingMetadata;
use Ecotone\Modelling\Attribute\CommandHandler;
use Ecotone\Redis\RedisBackedMessageChannelBuilder;
use Ecotone\Sqs\SqsBackedMessageChannelBuilder;
use Ecotone\Test\LicenceTesting;
use Enqueue\AmqpExt\AmqpConnectionFactory;
use Enqueue\Dbal\DbalConnectionFactory;
use Enqueue\Redis\RedisConnectionFactory;
use Enqueue\Sqs\SqsConnectionFactory;
use PhpBench\Attributes\BeforeMethods;
use PhpBench\Attributes\Iterations;
use PhpBench\Attributes\Revs;
use PhpBench\Attributes\Warmup;

/**
* Measures how fast the whole DBAL outbox is drained and handed over to the next channel of a combined channel:
* message-by-message forwarding (no enterprise licence) against batched SQL drain-and-forward (enterprise).
* The consumer is warmed up before messages are published, so only steady-state relay work is measured.
* The in-memory target subjects isolate the producing side of the relay; the provider subjects show the full
* path into real brokers receiving whole batches at once.
*/
#[Warmup(0), Revs(1), Iterations(5)]
class OutboxRelayBenchmark
{
private const AMOUNT_OF_RELAYED_MESSAGES = 10_000;

private const MESSAGE_PAYLOAD = 'benchmark order payload for outbox relay comparison';

private FlowTestSupport $messaging;

public function setUpRelayMessageByMessage(): void
{
$this->messaging = $this->bootstrapOutbox(licenceKey: null);
$this->warmUpConsumer();
$this->fillOutbox();
}

public function setUpRelayBatched(): void
{
$this->messaging = $this->bootstrapOutbox(licenceKey: LicenceTesting::VALID_LICENCE);
$this->warmUpConsumer();
$this->fillOutbox();
}

public function setUpRelaySingleBatch(): void
{
$this->messaging = $this->bootstrapOutbox(licenceKey: LicenceTesting::VALID_LICENCE, maxForwardingBatchSize: self::AMOUNT_OF_RELAYED_MESSAGES);
$this->warmUpConsumer();
$this->fillOutbox();
}

public function setUpRelayBatchedIntoDbalTarget(): void
{
$this->messaging = $this->bootstrapOutbox(licenceKey: LicenceTesting::VALID_LICENCE, targetProvider: 'dbal');
$this->warmUpConsumer();
$this->fillOutbox();
}

public function setUpRelayBatchedIntoAmqpTarget(): void
{
$this->messaging = $this->bootstrapOutbox(licenceKey: LicenceTesting::VALID_LICENCE, targetProvider: 'amqp');
$this->warmUpConsumer();
$this->fillOutbox();
}

public function setUpRelayBatchedIntoKafkaTarget(): void
{
$this->messaging = $this->bootstrapOutbox(licenceKey: LicenceTesting::VALID_LICENCE, targetProvider: 'kafka');
$this->warmUpConsumer();
$this->fillOutbox();
}

public function setUpRelayBatchedIntoRedisTarget(): void
{
$this->messaging = $this->bootstrapOutbox(licenceKey: LicenceTesting::VALID_LICENCE, targetProvider: 'redis');
$this->warmUpConsumer();
$this->fillOutbox();
}

public function setUpRelayBatchedIntoSqsTarget(): void
{
$this->messaging = $this->bootstrapOutbox(licenceKey: LicenceTesting::VALID_LICENCE, targetProvider: 'sqs');
$this->warmUpConsumer();
$this->fillOutbox();
}

#[BeforeMethods('setUpRelayMessageByMessage')]
public function bench_dbal_outbox_drain_message_by_message(): void
{
$this->drainWholeOutbox();
}

#[BeforeMethods('setUpRelayBatched')]
public function bench_dbal_outbox_drain_batched(): void
{
$this->drainWholeOutbox();
}

#[BeforeMethods('setUpRelaySingleBatch')]
public function bench_dbal_outbox_drain_as_single_batch(): void
{
$this->drainWholeOutbox();
}

#[BeforeMethods('setUpRelayBatchedIntoDbalTarget')]
public function bench_dbal_outbox_drain_batched_into_high_throughput_dbal_target(): void
{
$this->drainWholeOutbox();
}

#[BeforeMethods('setUpRelayBatchedIntoAmqpTarget'), Iterations(3)]
public function bench_dbal_outbox_drain_batched_into_rabbitmq_target(): void
{
$this->drainWholeOutbox();
}

#[BeforeMethods('setUpRelayBatchedIntoKafkaTarget'), Iterations(3)]
public function bench_dbal_outbox_drain_batched_into_kafka_target(): void
{
$this->drainWholeOutbox();
}

#[BeforeMethods('setUpRelayBatchedIntoRedisTarget'), Iterations(3)]
public function bench_dbal_outbox_drain_batched_into_redis_target(): void
{
$this->drainWholeOutbox();
}

#[BeforeMethods('setUpRelayBatchedIntoSqsTarget'), Iterations(3)]
public function bench_dbal_outbox_drain_batched_into_sqs_target(): void
{
$this->drainWholeOutbox();
}

private function warmUpConsumer(): void
{
$context = (new DbalConnectionFactory(self::databaseDsn()))->createContext();
$context->createDataBaseTable();
$context->purgeQueue($context->createQueue('benchmark_outbox'));
$context->purgeQueue($context->createQueue('benchmark_target'));

$this->messaging->sendCommandWithRoutingKey('benchmark.relayOrder', self::MESSAGE_PAYLOAD);
$this->messaging->run('benchmark_outbox', ExecutionPollingMetadata::createWithFinishWhenNoMessages());
}

private function drainWholeOutbox(): void
{
$this->messaging->run('benchmark_outbox', ExecutionPollingMetadata::createWithFinishWhenNoMessages());
}

private static function databaseDsn(): string
{
return getenv('DATABASE_DSN') ?: 'pgsql://ecotone:secret@localhost:5432/ecotone';
}

private function fillOutbox(): void
{
for ($messageNumber = 0; $messageNumber < self::AMOUNT_OF_RELAYED_MESSAGES; $messageNumber++) {
$this->messaging->sendCommandWithRoutingKey('benchmark.relayOrder', self::MESSAGE_PAYLOAD);
}
}

private function bootstrapOutbox(?string $licenceKey, ?int $maxForwardingBatchSize = null, string $targetProvider = 'in_memory'): FlowTestSupport
{
$targetName = in_array($targetProvider, ['in_memory', 'dbal'], true) ? 'benchmark_target' : uniqid('benchmark_target_');
if ($licenceKey !== null) {
$relayChannel = OutboxForwardingMessageChannel::create('benchmark_relay_orders', 'benchmark_outbox', $targetName);
if ($maxForwardingBatchSize !== null) {
$relayChannel = $relayChannel->withMaxForwardingBatchSize($maxForwardingBatchSize);
}
} else {
$relayChannel = CombinedMessageChannel::create('benchmark_relay_orders', ['benchmark_outbox', $targetName]);
}
[$targetChannel, $targetServices, $targetPackage] = match ($targetProvider) {
'in_memory' => [SimpleMessageChannelBuilder::createQueueChannel($targetName), [], null],
'dbal' => [DbalBackedMessageChannelBuilder::create($targetName)->withHighThroughputPublishing(), [], null],
'amqp' => [
AmqpBackedMessageChannelBuilder::create($targetName)->withHighThroughputPublishing(),
[AmqpConnectionFactory::class => new AmqpConnectionFactory(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f'])],
ModulePackageList::AMQP_PACKAGE,
],
'kafka' => [
KafkaMessageChannelBuilder::create($targetName, topicName: $targetName, messageGroupId: $targetName)->withHighThroughputPublishing(),
[KafkaBrokerConfiguration::class => KafkaBrokerConfiguration::createWithDefaults([getenv('KAFKA_DSN') ?: 'localhost:9094'])],
ModulePackageList::KAFKA_PACKAGE,
],
'redis' => [
RedisBackedMessageChannelBuilder::create($targetName)->withHighThroughputPublishing(),
[RedisConnectionFactory::class => new RedisConnectionFactory(getenv('REDIS_DSN') ?: 'redis://localhost:6379')],
ModulePackageList::REDIS_PACKAGE,
],
'sqs' => [
SqsBackedMessageChannelBuilder::create($targetName)->withHighThroughputPublishing(),
[SqsConnectionFactory::class => new SqsConnectionFactory(getenv('SQS_DSN') ?: 'sqs:?key=key&secret=secret&region=us-east-1&endpoint=http://localhost:4566&version=latest')],
ModulePackageList::SQS_PACKAGE,
],
};

$orderService = new class () {
#[Asynchronous('benchmark_relay_orders')]
#[CommandHandler('benchmark.relayOrder', endpointId: 'benchmarkRelayOrderEndpoint')]
public function handle(string $order): void
{
}
};

return EcotoneLite::bootstrapFlowTesting(
[$orderService::class],
array_merge(
[
DbalConnectionFactory::class => new DbalConnectionFactory(self::databaseDsn()),
$orderService,
],
$targetServices,
),
ServiceConfiguration::createWithDefaults()
->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept(array_merge(
[ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::DBAL_PACKAGE],
$targetPackage !== null ? [$targetPackage] : [],
)))
->withExtensionObjects([
$relayChannel,
DbalBackedMessageChannelBuilder::create('benchmark_outbox')
->withReceiveTimeout(20),
$targetChannel,
]),
licenceKey: $licenceKey,
);
}
}
2 changes: 1 addition & 1 deletion packages/Amqp/src/AmqpBackedMessageChannelBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public function withPublisherConfirms(bool $enabled): self
return $this;
}

public function withAsyncPublishing(bool $enabled = true, ?int $timeoutInMilliseconds = null): self
public function withHighThroughputPublishing(bool $enabled = true, ?int $timeoutInMilliseconds = null): self
{
$this->getAmqpOutboundChannelAdapter()->withAsyncPublishing($enabled, $timeoutInMilliseconds);

Expand Down
4 changes: 2 additions & 2 deletions packages/Amqp/tests/Integration/AsyncPublishingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ public function test_batch_published_over_amqp_lib_connection_is_delivered(): vo
->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::AMQP_PACKAGE]))
->withExtensionObjects([
AmqpBackedMessageChannelBuilder::create('asyncOrdersChannel', queueName: $channelName)
->withAsyncPublishing(),
->withHighThroughputPublishing(),
]),
licenceKey: LicenceTesting::VALID_LICENCE,
);
Expand Down Expand Up @@ -309,7 +309,7 @@ private function bootstrapEcotone(string $channelName, object $orderService, ?st
->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::AMQP_PACKAGE]))
->withExtensionObjects([
AmqpBackedMessageChannelBuilder::create('asyncOrdersChannel', queueName: $channelName)
->withAsyncPublishing(),
->withHighThroughputPublishing(),
]),
licenceKey: $licenceKey,
);
Expand Down
Loading
Loading