diff --git a/README.md b/README.md index 8f39156e..fa143581 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ For production use, you should install an adapter package that matches your mess See the [adapter list](docs/guide/en/adapter-list.md) and follow the adapter-specific documentation for installation and configuration details. > If you don't have an external broker — whether for development, testing, or because you want to -> design around `QueueProducerInterface` from day one and add a real broker later — you can run the queue +> start with a concrete producer and add a real broker later — you can run the queue > in [synchronous mode](docs/guide/en/synchronous-mode.md) using `SyncQueueProducer` instead of `AsyncQueueProducer`. > In this mode messages are processed immediately in the same process, so it won't provide true > async execution, but the code stays the same when you switch to a real adapter. @@ -138,11 +138,11 @@ For setting up all classes manually, see the [Manual configuration](docs/guide/e To send a message to the queue, get the queue instance and call `push()`. Typically the queue is injected as a dependency: ```php -use Yiisoft\Queue\QueueProducerInterface; +use Yiisoft\Queue\AsyncQueueProducer; final readonly class Foo { - public function __construct(private QueueProducerInterface $queue) {} + public function __construct(private AsyncQueueProducer $queue) {} public function bar(): void { @@ -166,10 +166,14 @@ By default, Yii Framework uses [yiisoft/yii-console](https://github.com/yiisoft/ See [Console commands](docs/guide/en/console-commands.md) for more details. -Producers use `Yiisoft\Queue\QueueProducerInterface` (`push()`, `status()`, `getQueueName()`); consumers use `Yiisoft\Queue\QueueConsumerInterface` (`run()`, `listen()`). See [capability configuration](docs/guide/en/queue-capabilities.md) for the strict role map used when named queues are configured. +`AsyncQueueProducer` and `SyncQueueProducer` provide `push()`, `getStatus()`, and `getQueueName()`; `QueueConsumer` provides `run()` and `listen()`, while `Yiisoft\Queue\Worker\Worker` processes messages. For named queues, use the queue-keyed `QueueProducerStatusProvider` to obtain a producer's status capability. See [capability configuration](docs/guide/en/queue-capabilities.md) for the strict role map used when named queues are configured. > In case you're running the queue in synchronous mode (no adapter), `queue:listen` logs an info message and exits. The messages are processed immediately when pushed. +#### Migration note + +`QueueProducerInterface`, `QueueConsumerInterface`, and `WorkerInterface` were removed. Custom implementations using these symbols are unsupported; use the concrete producer, consumer, and worker APIs instead. + ## Documentation - [Guide](docs/guide/en/README.md) diff --git a/composer.json b/composer.json index 91ca93d6..f6ab7884 100644 --- a/composer.json +++ b/composer.json @@ -55,6 +55,7 @@ "shipmonk/composer-dependency-analyser": "^1.8", "vimeo/psalm": "^5.26.1 || ^6.16.1", "yiisoft/code-style": "^1.0", + "yiisoft/di": "^1.4", "yiisoft/test-support": "^3.2.0", "yiisoft/yii-debug": "dev-master" }, diff --git a/config/di.php b/config/di.php index e8ae8707..e366866a 100644 --- a/config/di.php +++ b/config/di.php @@ -21,17 +21,37 @@ use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactoryInterface; +use Yiisoft\Queue\Middleware\Worker\WorkerMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\Worker\WorkerMiddlewareFactory; +use Yiisoft\Queue\Middleware\Worker\WorkerMiddlewareFactoryInterface; +use Yiisoft\Queue\Provider\QueueProducerStatusProvider; +use Yiisoft\Queue\Provider\QueueProducerStatusProviderInterface; use Yiisoft\Queue\Message\Handler\HandlerResolver; -use Yiisoft\Queue\Worker\Worker as QueueWorker; -use Yiisoft\Queue\Worker\WorkerInterface; +use Yiisoft\Queue\Debug\Middleware\PushDebugMiddleware; +use Yiisoft\Queue\Debug\Middleware\WorkerDebugMiddleware; +use Yiisoft\Queue\Worker\Worker; +use Yiisoft\Yii\Debug\Collector\SummaryCollectorInterface; /* @var array $params */ -return [ +$debugEnabled = (bool) ($params['yiisoft/yii-debug']['enabled'] ?? false) + && interface_exists(SummaryCollectorInterface::class); + +$pushMiddlewareDefinitions = array_merge( + $debugEnabled ? [PushDebugMiddleware::class] : [], + $params['yiisoft/queue']['middlewares-push'], +); +$workerMiddlewareDefinitions = array_merge( + $debugEnabled ? [WorkerDebugMiddleware::class] : [], + $params['yiisoft/queue']['middlewares-worker'] ?? [], +); + +$definitions = [ HandlerResolver::class => [ '__construct()' => [$params['yiisoft/queue']['handlers']], ], - WorkerInterface::class => QueueWorker::class, + Worker::class => Worker::class, + QueueProducerStatusProviderInterface::class => QueueProducerStatusProvider::class, LoopInterface::class => static function (ContainerInterface $container): LoopInterface { return \extension_loaded('pcntl') ? $container->get(SignalLoop::class) @@ -41,7 +61,11 @@ ConsumeMiddlewareFactoryInterface::class => ConsumeMiddlewareFactory::class, FailureMiddlewareFactoryInterface::class => FailureMiddlewareFactory::class, PushMiddlewareConfig::class => [ - '__construct()' => ['commonMiddlewareDefinitions' => $params['yiisoft/queue']['middlewares-push']], + '__construct()' => ['commonMiddlewareDefinitions' => $pushMiddlewareDefinitions], + ], + WorkerMiddlewareFactoryInterface::class => WorkerMiddlewareFactory::class, + WorkerMiddlewareDispatcher::class => [ + '__construct()' => ['middlewareDefinitions' => $workerMiddlewareDefinitions], ], ConsumeMiddlewareDispatcher::class => [ '__construct()' => ['middlewareDefinitions' => $params['yiisoft/queue']['middlewares-consume']], @@ -58,3 +82,5 @@ ], ], ]; + +return $definitions; diff --git a/config/params.php b/config/params.php index e502d23a..b37e7c73 100644 --- a/config/params.php +++ b/config/params.php @@ -6,14 +6,10 @@ use Yiisoft\Queue\Command\ListenCommand; use Yiisoft\Queue\Command\RunCommand; use Yiisoft\Queue\Debug\QueueCollector; -use Yiisoft\Queue\Debug\QueueConsumerProviderProxy; -use Yiisoft\Queue\Debug\QueueProducerProviderProxy; -use Yiisoft\Queue\Debug\QueueWorkerInterfaceProxy; +use Yiisoft\Queue\Debug\QueueProducerStatusProviderProxy; use Yiisoft\Queue\Message\Handler\HandlerInterface; use Yiisoft\Queue\Message\Serializer\MessageSerializer; -use Yiisoft\Queue\Provider\QueueConsumerProviderInterface; -use Yiisoft\Queue\Provider\QueueProducerProviderInterface; -use Yiisoft\Queue\Worker\WorkerInterface; +use Yiisoft\Queue\Provider\QueueProducerStatusProviderInterface; return [ 'yiisoft/yii-console' => [ @@ -44,6 +40,7 @@ */ 'handlers' => [], 'middlewares-push' => [], + 'middlewares-worker' => [], 'middlewares-consume' => [], 'middlewares-fail' => [], ], @@ -52,9 +49,7 @@ QueueCollector::class, ], 'trackedServices' => [ - QueueProducerProviderInterface::class => [QueueProducerProviderProxy::class, QueueCollector::class], - QueueConsumerProviderInterface::class => [QueueConsumerProviderProxy::class, QueueCollector::class], - WorkerInterface::class => [QueueWorkerInterfaceProxy::class, QueueCollector::class], + QueueProducerStatusProviderInterface::class => [QueueProducerStatusProviderProxy::class, QueueCollector::class], ], ], ]; diff --git a/docs/guide/en/configuration-manual.md b/docs/guide/en/configuration-manual.md index 2938b5ff..4cc81548 100644 --- a/docs/guide/en/configuration-manual.md +++ b/docs/guide/en/configuration-manual.md @@ -94,15 +94,15 @@ $provider = new PredefinedQueueProvider([ ## Running the queue -Message consumption methods are available on `Yiisoft\Queue\QueueConsumerInterface`. +Message consumption methods are available on `Yiisoft\Queue\QueueConsumer`. The producer and `QueueConsumer` are separate capabilities. Obtain or construct the consumer role before calling these methods. ### Processing existing messages ```php -use Yiisoft\Queue\QueueConsumerInterface; +use Yiisoft\Queue\QueueConsumer; -/** @var QueueConsumerInterface $queue */ +/** @var QueueConsumer $queue */ $queue->run(); // Process all messages $queue->run(10); // Process up to 10 messages ``` @@ -110,9 +110,9 @@ $queue->run(10); // Process up to 10 messages ### Listening for new messages ```php -use Yiisoft\Queue\QueueConsumerInterface; +use Yiisoft\Queue\QueueConsumer; -/** @var QueueConsumerInterface $queue */ +/** @var QueueConsumer $queue */ $queue->listen(); // Run indefinitely ``` diff --git a/docs/guide/en/debug-integration-advanced.md b/docs/guide/en/debug-integration-advanced.md index 283f9018..c34162c2 100644 --- a/docs/guide/en/debug-integration-advanced.md +++ b/docs/guide/en/debug-integration-advanced.md @@ -1,49 +1,50 @@ # Advanced Yii Debug integration -Use this guide when you need to understand which events are tracked by the queue collector, how proxy services operate, and how to wire the collector manually. +Use this guide to understand the optional native middleware and status-provider instrumentation used by the queue collector. ## What is collected -The integration is based on `Yiisoft\Queue\Debug\QueueCollector` and captures: +`Yiisoft\Queue\Debug\QueueCollector` can capture: -- Pushed messages grouped by queue name. -- Message status checks performed via `QueueProducerInterface::status()`. -- Messages processed by a worker grouped by queue name. +- pushed messages, grouped by their normalized queue key; +- status checks made through the producer status capability; +- worker processing events, grouped by queue key. -## How it works +There is no consumer processing metric or debug proxy. Push instrumentation does not report status checks, and status instrumentation does not wrap push calls. -The collector is enabled by registering it in Yii Debug and wrapping tracked services with proxy implementations. +## How it works -Out of the box (see this package's `config/params.php`), the following services are wrapped: +The integration uses native middleware: -- `Yiisoft\Queue\Provider\QueueProducerProviderInterface` is wrapped with `Yiisoft\Queue\Debug\QueueProducerProviderProxy`, which returns `QueueProducerDecorator` instances so `push()` and `status()` calls are reported. -- `Yiisoft\Queue\Provider\QueueConsumerProviderInterface` is wrapped with `Yiisoft\Queue\Debug\QueueConsumerProviderProxy`, which returns typed consumer decorators. -- `Yiisoft\Queue\Worker\WorkerInterface` is wrapped with `Yiisoft\Queue\Debug\QueueWorkerInterfaceProxy` to record message processing events. +- `Yiisoft\Queue\Debug\Middleware\PushDebugMiddleware` wraps the push pipeline. After the downstream/final push handler returns, it records the message from the returned `PushRequest` and the immutable normalized identity from the incoming `PushRequest`. The returned message is not necessarily adapter output: synchronous pushing may process and replace it. If downstream processing throws, no push event is recorded; a middleware that short-circuits before this middleware is reached also bypasses it. +- `Yiisoft\Queue\Debug\Middleware\WorkerDebugMiddleware` runs in the separate worker pipeline and records processing before handler resolution. The processing event is recorded before downstream handling, so a later exception does not remove it; middleware that short-circuits before this middleware is reached prevents it. +- `Yiisoft\Queue\Debug\QueueProducerStatusProviderProxy` wraps only `QueueProducerStatusProviderInterface`. It decorates the status capability returned by `getStatus($queueName)`; status-provider operations are not push or consumer-processing instrumentation. -To see data in the debug panel, obtain the typed provider dependencies and `WorkerInterface` from the DI container — the proxies are registered there and will not be active if the services are instantiated directly. +All of these are optional. Services instantiated directly are instrumented only when the corresponding middleware or provider wrapper is explicitly supplied. ## Manual configuration -If you do not rely on the defaults supplied via [yiisoft/config](https://github.com/yiisoft/config), configure the collector and proxies explicitly: +When using [yiisoft/config](https://github.com/yiisoft/config), configure `middlewares-push` and `middlewares-worker` as needed. Register the status provider wrapper only if status instrumentation is wanted: ```php +use Yiisoft\Queue\Debug\Middleware\PushDebugMiddleware; +use Yiisoft\Queue\Debug\Middleware\WorkerDebugMiddleware; use Yiisoft\Queue\Debug\QueueCollector; -use Yiisoft\Queue\Debug\QueueConsumerProviderProxy; -use Yiisoft\Queue\Debug\QueueProducerProviderProxy; -use Yiisoft\Queue\Debug\QueueWorkerInterfaceProxy; -use Yiisoft\Queue\Provider\QueueConsumerProviderInterface; -use Yiisoft\Queue\Provider\QueueProducerProviderInterface; -use Yiisoft\Queue\Worker\WorkerInterface; +use Yiisoft\Queue\Debug\QueueProducerStatusProviderProxy; +use Yiisoft\Queue\Provider\QueueProducerStatusProviderInterface; return [ + 'yiisoft/queue' => [ + 'middlewares-push' => [PushDebugMiddleware::class], + 'middlewares-worker' => [WorkerDebugMiddleware::class], + ], 'yiisoft/yii-debug' => [ - 'collectors' => [ - QueueCollector::class, - ], + 'collectors' => [QueueCollector::class], 'trackedServices' => [ - QueueProducerProviderInterface::class => [QueueProducerProviderProxy::class, QueueCollector::class], - QueueConsumerProviderInterface::class => [QueueConsumerProviderProxy::class, QueueCollector::class], - WorkerInterface::class => [QueueWorkerInterfaceProxy::class, QueueCollector::class], + QueueProducerStatusProviderInterface::class => [ + QueueProducerStatusProviderProxy::class, + QueueCollector::class, + ], ], ], ]; diff --git a/docs/guide/en/debug-integration.md b/docs/guide/en/debug-integration.md index f58831ca..461d00d1 100644 --- a/docs/guide/en/debug-integration.md +++ b/docs/guide/en/debug-integration.md @@ -1,11 +1,9 @@ # Yii Debug integration -This package provides an integration with [yiisoft/yii-debug](https://github.com/yiisoft/yii-debug). +This package provides optional integration with [yiisoft/yii-debug](https://github.com/yiisoft/yii-debug). -When Yii Debug is enabled, the queue collector adds a panel that shows pushed messages, message status checks, and worker activity. +When enabled, optional native push and worker middleware add queue events to the queue collector. Push events contain the normalized queue key and the message from the final push handler's returned `PushRequest`; this is not necessarily a message returned by an adapter (for example, synchronous pushing can process and replace the message). Worker events are recorded before handler resolution. A push event is recorded only when the downstream push handler returns; an exception prevents that event, and middleware that short-circuits before the debug middleware is reached bypasses it. Status instrumentation is separate and wraps only the `QueueProducerStatusProviderInterface` capability. -If you use [yiisoft/config](https://github.com/yiisoft/config) together with this package, the debug collector is registered automatically. For manual configuration snippets and proxy wiring details, see [Advanced Yii Debug integration](debug-integration-advanced.md). +The instrumentation is optional: applications can enable or omit each middleware and the status provider wrapper independently. There are no producer, consumer, or worker debug proxy/decorator services. -## See also - -- [Advanced Yii Debug integration](debug-integration-advanced.md) — collector internals, proxies, and manual wiring +For manual wiring and tracked events, see [Advanced Yii Debug integration](debug-integration-advanced.md). diff --git a/docs/guide/en/error-handling-advanced.md b/docs/guide/en/error-handling-advanced.md index 2f0ff68f..e3407bbb 100644 --- a/docs/guide/en/error-handling-advanced.md +++ b/docs/guide/en/error-handling-advanced.md @@ -19,7 +19,7 @@ This document covers advanced internals of the failure handling pipeline, built- - the message - the caught exception - the logical queue name - - an optional direct retry producer (provided for synchronous producer execution) + - an optional direct retry target, a `Closure(MessageInterface): MessageInterface` (provided by the worker when available) 4. A failure pipeline is selected by queue name @@ -84,7 +84,7 @@ This interface has the only method `processFailure` with these parameters: - [`FailureHandlingRequest $request`](../../../src/Middleware/FailureHandling/FailureHandlingRequest.php) - a request for a message handling. It consists of - a [message](../../../src/Message/MessageInterface.php) - a `Throwable $exception` object thrown on the `request` handling - - the logical queue name the message came from and, when available, a direct retry producer + - the logical queue name the message came from and, when available, a direct retry target closure (`Closure(MessageInterface): MessageInterface`) - `FailureHandlerInterface $handler` - failure strategy pipeline continuation. Your Middleware should call `$handler->handleFailure($request)` when the middleware itself should not interrupt failure pipeline execution. -> Note: your strategy have to check by its own if it should be applied. Look into [`SendAgainMiddleware::suits()`](../../../src/Middleware/FailureHandling/Implementation/SendAgainMiddleware.php#L54) for an example. +> Note: your strategy have to check by its own if it should be applied. Look into [`SendAgainMiddleware::suits()`](../../../src/Middleware/FailureHandling/Implementation/SendAgainMiddleware.php#L64) for an example. diff --git a/docs/guide/en/error-handling.md b/docs/guide/en/error-handling.md index 71c14fcd..f32f2b95 100644 --- a/docs/guide/en/error-handling.md +++ b/docs/guide/en/error-handling.md @@ -38,7 +38,9 @@ Here below is configuration via [yiisoft/config](https://github.com/yiisoft/conf static fn (QueueProducerProviderInterface $queues) => new SendAgainMiddleware( id: 'default-second-resend', maxAttempts: 1, - targetQueue: $queues->getProducer('failed-messages'), + targetQueue: static function (MessageInterface $message) use ($queues): MessageInterface { + return $queues->getProducer('failed-messages')->push($message); + }, ), ], 'failed-messages' => [ @@ -87,8 +89,8 @@ Failures of messages that arrived in the `failed-messages` queue directly (bypas - `id` - A unique string. Allows to use this strategy more than once for the same message, just like in example above. - `maxAttempts` - Maximum attempts count for this strategy with the given $id before it will give up. - - `targetQueue` - An optional `QueueProducerInterface` for an explicit retry destination. When it is `null`, synchronous execution supplies its originating producer; asynchronous execution resolves the originating queue name through `producerProvider`. - - `producerProvider` - The `QueueProducerProviderInterface` used to resolve the source producer for asynchronous retries when no `targetQueue` is supplied. Configure it, or provide `targetQueue`; otherwise retry fails with a configuration error. + - `targetQueue` - An optional `Closure(MessageInterface): MessageInterface` retry target. The closure receives the failed message, pushes it to the target queue, and returns the message returned by the producer. + - `producerProvider` - The `QueueProducerProviderInterface` used to create a retry closure for the originating queue when no `targetQueue` or worker-supplied retry closure is available. Configure it, or provide `targetQueue`; otherwise retry fails with a configuration error. State tracking: @@ -106,8 +108,8 @@ It's configured via constructor parameters, too. Here they are: - `delayInitial` - The initial delay that will be applied to a message for the first time. It must be a positive float. - `delayMaximum` - The maximum delay which can be applied to a single message. Must be above the `delayInitial`. - `exponent` - Message handling delay will be multiplied by exponent each time it fails. - - `queue` - An optional `QueueProducerInterface` retry destination. When it is `null`, synchronous execution supplies its originating producer; asynchronous execution resolves the originating queue name through `producerProvider`. - - `producerProvider` - The `QueueProducerProviderInterface` used for that asynchronous source-producer lookup. + - `queue` - An optional `Closure(MessageInterface): MessageInterface` retry target. The closure receives the failed message, pushes it to the target queue, and returns the message returned by the producer. + - `producerProvider` - The `QueueProducerProviderInterface` used to create a retry closure for the originating queue when no `queue` or worker-supplied retry closure is available. Requirements: diff --git a/docs/guide/en/message-status.md b/docs/guide/en/message-status.md index 702841bb..67148902 100644 --- a/docs/guide/en/message-status.md +++ b/docs/guide/en/message-status.md @@ -2,16 +2,13 @@ Yii Queue can report the status of a message by its ID. -The API surface is: +Status is a producer capability exposed by `getStatus(): QueueProducerStatusInterface`. Call `status(string|int $id)` on the returned capability, not on the producer itself. For named queues, `QueueProducerStatusProviderInterface` is keyed by the logical queue name; its `getStatus($queueName)` method returns the capability for that queue. -- `QueueProducerInterface::status(string|int $id): MessageStatus` -- `AdapterInterface::status(string|int $id): MessageStatus` - -Status tracking support depends on the adapter. If an adapter doesn't support status tracking or can't find the message by ID, it returns `MessageStatus::NOT_FOUND`. +`AsyncQueueProducer` uses adapter-backed status tracking by default. `SyncQueueProducer` uses a status capability that returns `MessageStatus::NOT_FOUND` by default; pass a custom capability when synchronous status tracking is available. For an asynchronous adapter that doesn't support status tracking or can't find the message by ID, it also returns `MessageStatus::NOT_FOUND`. ## Getting a message ID -`QueueProducerInterface::push()` returns a `MessageInterface`. When the adapter supports IDs, the returned message is typically wrapped into an `IdEnvelope`, which stores the ID in message metadata. +The examples below assume `$queue` is the configured producer for the default queue and `$message` is the message to push. A producer's `push()` method returns a `MessageInterface`. When the adapter supports IDs, the adapter-returned message is typically wrapped into an `IdEnvelope`, which stores the ID in message metadata. To read the ID: @@ -61,7 +58,7 @@ if ($id === null) { throw new \RuntimeException('The adapter did not provide a message ID, status tracking is unavailable.'); } -$status = $queue->status($id); +$status = $queue->getStatus()->status($id); if ($status === MessageStatus::WAITING) { // The message is waiting to be handled. @@ -76,6 +73,19 @@ if ($status === MessageStatus::DONE) { } ``` +## Named queues + +Use the status provider with the same logical queue name used by the producer. The provider returns the configured producer's status capability: + +```php +use Yiisoft\Queue\Provider\QueueProducerStatusProviderInterface; + +// `$statusProvider` is the configured QueueProducerStatusProviderInterface. +$status = $statusProvider->getStatus('emails')->status($id); +``` + +`hasStatus($queueName)` can be used to check whether a producer status capability is configured, and `getStatusQueueNames()` lists the available queue keys. For an unknown queue, or a queue without a producer, `getStatus($queueName)` throws `QueueNotFoundException`; it does not return `MessageStatus::NOT_FOUND`. `NOT_FOUND` is the result for a known queue when the message ID is unknown or status tracking is unsupported. + ## Edge cases - **Unknown ID or unsupported tracking** diff --git a/docs/guide/en/middleware-pipelines.md b/docs/guide/en/middleware-pipelines.md index 9828c19d..bcffe885 100644 --- a/docs/guide/en/middleware-pipelines.md +++ b/docs/guide/en/middleware-pipelines.md @@ -4,7 +4,7 @@ Yii Queue uses middlewares to run custom logic around message pushing and messag A middleware is a piece of code that receives a request object and can either: -- change the request (for example, change the message, adapter, or error handling behavior) and continue the pipeline, or +- return a modified request (for example, one with a changed message or error-handling data) and continue the pipeline, or - stop the pipeline by returning without calling the next handler. The pipeline mechanism is similar to HTTP middleware, but applied to queue messages. @@ -21,15 +21,16 @@ Common reasons to add middlewares: You can log message ids, queue names, attempts, and failures in a consistent way. - **Modify the message payload** You can obfuscate sensitive data, normalize payload, add extra fields required by consumers, or wrap a message into envelopes. -- **Route and schedule** - You can switch queue, choose a different adapter, or add delay when the adapter supports it. +- **Schedule** + You can add delay when the adapter supports it. ## Pipelines overview -Each message may pass through three independent pipelines: +A message may pass through four independent pipelines: -- **Push pipeline** (executed when calling `QueueProducerInterface::push()`). -- **Consume pipeline** (executed when a worker processes a message). +- **Push pipeline** (executed when a producer pushes a message). +- **Worker pipeline** (executed by `Worker` before handler resolution). +- **Consume pipeline** (executed while the worker invokes the resolved handler). - **Failure handling pipeline** (executed when message processing throws a `Throwable`). The execution order inside a pipeline is forward in the same order you configured middlewares. @@ -41,9 +42,9 @@ graph LR PushMiddleware1[$middleware1] -.-> EndPush((End)) - StartConsume((Start)) --> ConsumeMiddleware1[$middleware1] --> ConsumeMiddleware2[$middleware2] --> Consume(Consume / handle) - -.-> ConsumeMiddleware2[$middleware2] -.-> ConsumeMiddleware1[$middleware1] - ConsumeMiddleware1[$middleware1] -.-> EndConsume((End)) + StartWorker((Start)) --> WorkerMiddleware1[$workerMiddleware1] --> WorkerMiddleware2[$workerMiddleware2] --> Consume(Resolve handler / consume) + -.-> WorkerMiddleware2[$workerMiddleware2] -.-> WorkerMiddleware1[$workerMiddleware1] + WorkerMiddleware1[$workerMiddleware1] -.-> EndWorker((End)) Consume -- Throwable --> StartFailure((Start failure)) @@ -67,50 +68,39 @@ You can use any of these formats: The required interface depends on the pipeline: - Push: `Yiisoft\Queue\Middleware\Push\PushMiddlewareInterface` +- Worker: `Yiisoft\Queue\Middleware\Worker\WorkerMiddlewareInterface` - Consume: `Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareInterface` - Failure handling: `Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareInterface` ## Push pipeline -The push pipeline is executed when calling `QueueProducerInterface::push()`. +The push pipeline is a request-in/request-out pipeline. It receives a `PushRequest` and returns a `PushRequest`; requests are immutable. The request normalizes the logical queue identity once, so `getQueueName()` always returns the same normalized queue key throughout the pipeline. -Push middlewares can: +Push middlewares can modify the message, for example by wrapping it in envelopes or adding metadata. The normalized queue identity in `PushRequest` is immutable: push middleware cannot switch queues or adapters. The adapter final handler performs the push and returns the adapter-returned message in the resulting request. -- Modify the message (wrap it into envelopes, add metadata, obfuscate data, etc.). -- Modify the adapter (add delay, route to a different backend, etc.). - -In particular, push middlewares may define or replace the adapter that will be used to push the message. This can be useful when: - -- You choose a backend dynamically (for example, based on message type or payload). -- You route messages to different queues/backends (for example, `critical` vs `low`). -- You apply scheduling/delay logic in a middleware. +### Custom push middleware -The adapter is set by returning a modified request: +Implement `PushMiddlewareInterface` and return a modified `PushRequest` from `processPush()`: ```php -return $pushRequest->withAdapter($adapter); +public function processPush(PushRequest $request, PushHandlerInterface $handler): PushRequest +{ + $result = $handler->handlePush($request); + return $result->withMessage($this->normalize($result->getMessage())); +} ``` -### Adapter must be configured by the end of the pipeline +`PushRequest` has no mutable adapter or queue field. Adapter and queue selection belong to producer configuration; the final handler preserves the message returned by that adapter. -The push pipeline ends with a final handler that actually pushes the message using the adapter. +## Worker pipeline -If the adapter is not configured by the time the pipeline reaches the final handler, -`Yiisoft\Queue\Exception\AdapterNotConfiguredException` is thrown. +The worker pipeline is a separate request-in/request-out pipeline. `Worker` runs it before resolving the message handler. The resolved handler is then processed by the consume pipeline; if processing throws, the failure handling pipeline runs. Worker-level instrumentation and processing event semantics belong here. Configure it with `middlewares-worker`. -### Custom push middleware - -Implement `PushMiddlewareInterface` and return a modified `PushRequest` from `processPush()`: - -```php -return $pushRequest - ->withMessage($newMessage) - ->withAdapter($newAdapter); -``` +Implement `WorkerMiddlewareInterface` and return a `WorkerRequest` from `processWorker()`. ## Consume pipeline -The consume pipeline is executed by the worker while processing a message. +The consume pipeline is a request-in/request-out pipeline executed by `Worker` while processing a message, after the worker pipeline and handler resolution. Consume middlewares are often used to modify the message and/or collect runtime information: @@ -128,7 +118,10 @@ The pipeline receives a `FailureHandlingRequest` that contains: - the message - the caught exception -- the queue instance +- the normalized queue name +- an optional typed retry `Closure(MessageInterface): MessageInterface` + +It does not contain a queue instance. The pipeline is selected by queue name; if there is no queue-specific pipeline configured, `FailureMiddlewareDispatcher::DEFAULT_PIPELINE` is used. @@ -142,6 +135,7 @@ See [Error handling on message processing](error-handling.md) for the step-by-st When using [yiisoft/config](https://github.com/yiisoft/config), pipelines are configured in params under `yiisoft/queue`: - `middlewares-push` +- `middlewares-worker` - `middlewares-consume` - `middlewares-fail` diff --git a/docs/guide/en/migrating-from-yii2-queue.md b/docs/guide/en/migrating-from-yii2-queue.md index 8542ec02..ef3916a5 100644 --- a/docs/guide/en/migrating-from-yii2-queue.md +++ b/docs/guide/en/migrating-from-yii2-queue.md @@ -29,4 +29,14 @@ being consumed. In the new package, it is divided into two different concepts: a - A `Handler` is called by a `Worker` when a message is received. Default `Worker` finds a corresponding message handler by the message type. [See more](message-handler-advanced.md). +## Queue API migration note + +The following API symbols were removed: + +- `QueueProducerInterface` +- `QueueConsumerInterface` +- `WorkerInterface` + +Custom implementations using these symbols are unsupported. Use the concrete producer, consumer, and worker APIs documented in this guide. + [yiisoft/yii2-queue]: https://github.com/yiisoft/yii2-queue diff --git a/docs/guide/en/performance-tuning.md b/docs/guide/en/performance-tuning.md index b983939b..23731c2a 100644 --- a/docs/guide/en/performance-tuning.md +++ b/docs/guide/en/performance-tuning.md @@ -404,7 +404,7 @@ Test with realistic message volumes and data: ```php // Load test script -$queue = $container->get(QueueProducerInterface::class); +$queue = $container->get(AsyncQueueProducer::class); $start = microtime(true); $count = 10000; diff --git a/docs/guide/en/queue-capabilities.md b/docs/guide/en/queue-capabilities.md index 085e8b8c..8a625215 100644 --- a/docs/guide/en/queue-capabilities.md +++ b/docs/guide/en/queue-capabilities.md @@ -1,6 +1,6 @@ # Queue producer and consumer capabilities -A logical queue name can independently expose a producer, a consumer, or both. Inject `QueueProducerInterface` to push/status messages and `QueueConsumerInterface` to run/listen. Console commands use only `QueueConsumerProviderInterface`; retry middleware uses a direct `QueueProducerInterface` or `QueueProducerProviderInterface`. +A logical queue name can independently expose a producer, a consumer, or both. Use `SyncQueueProducer` or `AsyncQueueProducer` to push messages, and `QueueProducerStatusProviderInterface` to obtain status by queue key. Use `QueueConsumer` to run/listen; console commands use `QueueConsumerProviderInterface`. Retry middleware receives a typed `Closure(MessageInterface): MessageInterface` target. Named providers use a strict nested role map. `getProducerQueueNames()` and `getConsumerQueueNames()` return only names with that role. Role definitions are created lazily and cached per name and role; failed lazy creation is cached and repeated lookups rethrow the same configuration error. @@ -18,6 +18,6 @@ $definitions = [ ]; ``` -`QueueFactoryProvider` accepts factory definitions in each role. `PredefinedQueueProvider` uses the same outer shape but each role value must already be its respective interface instance. A raw definition such as `'orders' => ['class' => AsyncQueueProducer::class]`, an empty role map, and unknown role keys are invalid. +`QueueFactoryProvider` accepts factory definitions in each role. `PredefinedQueueProvider` uses the same outer shape but each role value must already be a configured producer or `QueueConsumer` instance. A raw definition such as `'orders' => ['class' => AsyncQueueProducer::class]`, an empty role map, and unknown role keys are invalid. -`QueueInterface`, `Queue`, and `QueueProviderInterface` were removed before release. Replace them with `QueueProducerInterface`, `SyncQueueProducer` / `AsyncQueueProducer` / `QueueConsumer`, and the relevant typed provider. Synchronous consumers retain no-op `run()` and `listen()` behavior when no adapter is configured. Default retry of an asynchronously consumed message resolves a producer for the execution queue name through a configured producer provider; if none is available it fails with an actionable configuration error rather than dropping the message. +Synchronous consumers retain no-op `run()` and `listen()` behavior when no adapter is configured. Default retry of an asynchronously consumed message resolves a producer for the execution queue name through a configured producer provider; if none is available it fails with an actionable configuration error rather than dropping the message. diff --git a/docs/guide/en/queue-names-advanced.md b/docs/guide/en/queue-names-advanced.md index a605815f..ed2edcea 100644 --- a/docs/guide/en/queue-names-advanced.md +++ b/docs/guide/en/queue-names-advanced.md @@ -8,8 +8,8 @@ Most applications configure names through [`yiisoft/queue.queues`](queue-names.m Providers translate a queue name into the capability the caller needs: -- `QueueProducerProviderInterface::getProducer($queueName)` returns a `QueueProducerInterface` for pushing messages and obtaining their status. -- `QueueConsumerProviderInterface::getConsumer($queueName)` returns a `QueueConsumerInterface` for running or listening for messages. +- `QueueProducerProviderInterface::getProducer($queueName)` returns a producer. Call its `getStatus()` capability for status operations. +- `QueueConsumerProviderInterface::getConsumer($queueName)` returns a `QueueConsumer` for running or listening for messages. - `hasProducer()` / `hasConsumer()` check whether a name exposes a role. `getProducerQueueNames()` / `getConsumerQueueNames()` list names for only that role. Both lookup methods accept a string or `BackedEnum`. They throw `QueueNotFoundException` when the name is unknown or does not have the requested role. This separation prevents a producer-only queue from accidentally being used by a worker, and vice versa. @@ -23,7 +23,7 @@ The built-in providers use a strict role map: `queues[name][producer|consumer]`. Choose the provider by how the roles are created: - Use `QueueFactoryProvider` when the values are [`yiisoft/factory`](https://github.com/yiisoft/factory) definitions. It creates and caches each role lazily, so resolving a producer does not construct the consumer for the same name. -- Use `PredefinedQueueProvider` when the values are already-built `QueueProducerInterface` or `QueueConsumerInterface` instances. It does not accept factory definitions. +- Use `PredefinedQueueProvider` when the values are already-built producer or `QueueConsumer` instances. It does not accept factory definitions. `QueueFactoryProvider` is appropriate for container configuration: diff --git a/docs/guide/en/queue-names.md b/docs/guide/en/queue-names.md index bee1d1bc..a07e52e7 100644 --- a/docs/guide/en/queue-names.md +++ b/docs/guide/en/queue-names.md @@ -2,7 +2,7 @@ A *queue name* is a logical identifier for independently configured producer and consumer capabilities. A name can have a producer, a consumer, or both; it does not imply that the two roles use the same object. -- Inject `QueueProducerInterface` to push messages to the default queue. +- Inject the configured producer (usually `AsyncQueueProducer`) to push messages to the default queue. - Use `QueueProducerProviderInterface` to obtain a named producer with `getProducer()`. - Use `QueueConsumerProviderInterface` to obtain a named consumer with `getConsumer()`; console commands use this provider. @@ -42,18 +42,18 @@ return [ ]; ``` -`QueueFactoryProvider` resolves these role definitions lazily and caches each role independently. `PredefinedQueueProvider` has the same shape, but each value must be an already-created instance of its role interface. +`QueueFactoryProvider` resolves these role definitions lazily and caches each role independently. `PredefinedQueueProvider` has the same shape, but each value must be an already-created producer or `QueueConsumer` instance. ## Producing messages For the default queue, inject the producer directly: ```php -use Yiisoft\Queue\QueueProducerInterface; +use Yiisoft\Queue\AsyncQueueProducer; final readonly class SendWelcomeEmail { - public function __construct(private QueueProducerInterface $queue) {} + public function __construct(private AsyncQueueProducer $queue) {} public function run(string $email): void { diff --git a/docs/guide/en/synchronous-mode.md b/docs/guide/en/synchronous-mode.md index efe73cdf..fe667b48 100644 --- a/docs/guide/en/synchronous-mode.md +++ b/docs/guide/en/synchronous-mode.md @@ -4,16 +4,16 @@ Run tasks synchronously in the same process. Useful for: - developing and debugging an application; - writing tests; -- production setups where the application is built around `QueueProducerInterface` from day one but - doesn't have an external broker yet — you can switch to a real adapter later without touching - the call sites. +- production setups where the application is built around a producer from day one but + doesn't have an external broker yet — you can switch to a real adapter later without changing + the producer call sites. To enable it, use `SyncQueueProducer` instead of `AsyncQueueProducer` — it takes a worker instead of an adapter: ```php $logger = $DIContainer->get(\Psr\Log\LoggerInterface::class); -$worker = $DIContainer->get(\Yiisoft\Queue\Worker\WorkerInterface::class); +$worker = $DIContainer->get(\Yiisoft\Queue\Worker\Worker::class); $pushMiddlewareConfig = $DIContainer->get( \Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig::class ); @@ -26,11 +26,11 @@ $producer = new \Yiisoft\Queue\SyncQueueProducer( ``` In synchronous mode every message passed to `$producer->push()` is processed immediately by the worker. -The value returned from `push()` is the message after push-middlewares — without an `IdEnvelope`, -since no adapter is involved to assign an ID. +The value returned from `push()` is the final result from the Push handler after worker processing, including +handler, consume, and failure transformations — without an `IdEnvelope`, since no adapter is involved to assign an ID. Limitations: - A separately configured `QueueConsumer` without an adapter has `run()` return `0`. - Its `listen()` logs an info message and returns without listening. -- `status()` always returns `MessageStatus::NOT_FOUND` — there is no message storage to track IDs. +- `getStatus()->status()` always returns `MessageStatus::NOT_FOUND` — there is no message storage to track IDs. diff --git a/docs/guide/en/usage.md b/docs/guide/en/usage.md index ba4de48c..961b0849 100644 --- a/docs/guide/en/usage.md +++ b/docs/guide/en/usage.md @@ -81,6 +81,8 @@ yii queue:listen-all [queueName1 [queueName2 [...]]] ## Message status +The example assumes `$queue` is the configured producer used above (typically an `AsyncQueueProducer`; a `SyncQueueProducer` keeps its default `NOT_FOUND` status behavior). + ```php use Yiisoft\Queue\MessageStatus; use Yiisoft\Queue\Message\IdEnvelope; @@ -92,7 +94,7 @@ if ($id === null) { throw new \RuntimeException('The adapter did not provide a message ID, status tracking is unavailable.'); } -$status = $queue->status($id); +$status = $queue->getStatus()->status($id); // Check whether the message is waiting to be handled. $status === MessageStatus::WAITING; @@ -104,7 +106,7 @@ $status === MessageStatus::RESERVED; $status === MessageStatus::DONE; ``` -For details and edge cases, see [Message status](message-status.md). +For named queues, obtain the status capability by queue key from the configured `QueueProducerStatusProviderInterface`; `getStatus($queueName)` throws `QueueNotFoundException` when that queue has no producer. For details and other edge cases, see [Message status](message-status.md). ## Limitations diff --git a/docs/guide/en/worker.md b/docs/guide/en/worker.md index b4e48a2a..acf49b57 100644 --- a/docs/guide/en/worker.md +++ b/docs/guide/en/worker.md @@ -1,9 +1,27 @@ # Worker -To use a worker, you should resolve the worker's dependencies (e.g., through DI container) and [define handlers](message-handler-advanced.md) for each message that will be consumed by a worker. +`Worker` processes messages and resolves their handlers. Before handler resolution, it runs the optional native worker middleware pipeline configured with `middlewares-worker`. -## Starting Workers +## Worker middleware -To start a worker, you should run the console commands such as `queue:run`, `queue:listen`, and `queue:listen-all`. See [Console commands](console-commands.md) for details. +Worker middleware receives an immutable `WorkerRequest` and returns a `WorkerRequest`. Use it for processing-level instrumentation and events that must cover the complete worker operation. In particular, worker processing events belong here rather than in a producer or consumer proxy. + +A worker middleware implements `WorkerMiddlewareInterface`: + +```php +public function processWorker(WorkerRequest $request, WorkerHandlerInterface $handler): WorkerRequest +{ + $this->started($request->getQueueName(), $request->getMessage()); + return $handler->handleWorker($request); +} +``` + +The request carries the normalized queue name, message, and an optional retry target typed as `Closure(MessageInterface): MessageInterface`. Retry strategies call that closure with a message; they do not receive a producer interface. + +## Starting workers + +Resolve `Worker` and its dependencies (for example, through a DI container), and [define handlers](message-handler-advanced.md) for each message consumed by the worker. + +Start a worker with console commands such as `queue:run`, `queue:listen`, and `queue:listen-all`. See [Console commands](console-commands.md) for details. For production-grade process management with `systemd`, Supervisor, or cron, see [Running workers in production](process-managers.md). diff --git a/src/AdapterQueueProducerStatus.php b/src/AdapterQueueProducerStatus.php new file mode 100644 index 00000000..2f91f5dc --- /dev/null +++ b/src/AdapterQueueProducerStatus.php @@ -0,0 +1,17 @@ +adapter->status($id); + } +} diff --git a/src/AsyncQueueProducer.php b/src/AsyncQueueProducer.php index 7d63c389..e87403e2 100644 --- a/src/AsyncQueueProducer.php +++ b/src/AsyncQueueProducer.php @@ -12,14 +12,16 @@ use Yiisoft\Queue\Middleware\Push\AdapterPushHandler; use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; use Yiisoft\Queue\Middleware\Push\PushMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\Push\PushRequest; /** * Produces messages for one logical queue, pushing them to an adapter-backed broker. */ -final class AsyncQueueProducer implements QueueProducerInterface +final class AsyncQueueProducer { private string $queueName; private PushMiddlewareDispatcher $dispatcher; + private QueueProducerStatusInterface $status; /** * @param mixed[] $middlewareDefinitions Queue-specific push middleware definitions. @@ -30,7 +32,9 @@ public function __construct( private readonly AdapterInterface $adapter, string|BackedEnum $queueName = DefaultQueue::NAME, array $middlewareDefinitions = [], + ?QueueProducerStatusInterface $status = null, ) { + $this->status = $status ?? new AdapterQueueProducerStatus($adapter); $this->queueName = StringNormalizer::normalize($queueName); $this->dispatcher = new PushMiddlewareDispatcher( middlewareFactory: $middlewareConfig->middlewareFactory, @@ -50,7 +54,9 @@ public function push(MessageInterface $message): MessageInterface 'Preparing to push message with message type "{messageType}".', ['messageType' => $message->getType()], ); - $message = $this->dispatcher->dispatch($message); + $message = $this->dispatcher->hasMiddlewares() + ? $this->dispatcher->dispatch(new PushRequest($message, $this->queueName))->getMessage() + : $this->adapter->push($message); $id = IdEnvelope::fromMessage($message)->getId(); $this->logger->info( $id === null @@ -61,8 +67,8 @@ public function push(MessageInterface $message): MessageInterface return $message; } - public function status(string|int $id): MessageStatus + public function getStatus(): QueueProducerStatusInterface { - return $this->adapter->status($id); + return $this->status; } } diff --git a/src/Debug/Middleware/PushDebugMiddleware.php b/src/Debug/Middleware/PushDebugMiddleware.php new file mode 100644 index 00000000..4a29cb5f --- /dev/null +++ b/src/Debug/Middleware/PushDebugMiddleware.php @@ -0,0 +1,86 @@ +sourceFrame(); + $result = $handler->handlePush($request); + $this->collector->collectPush( + $request->getQueueName(), + $result->getMessage(), + $source['file'] . ':' . $source['line'], + ); + return $result; + } + + /** + * Find the first frame outside the queue's push pipeline. + * + * The pipeline contains anonymous handlers, so its depth changes with the + * configured middleware. Looking at the owning files/classes keeps the + * external source stable when middleware is added or removed. + * + * @return array{file: string, line: int} + */ + private function sourceFrame(): array + { + /** @var list $trace */ + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + foreach ($trace as $frame) { + $file = $frame['file'] ?? null; + if (!is_string($file) || !isset($frame['line']) || $this->isPipelineFrame($frame, $file)) { + continue; + } + + return ['file' => $file, 'line' => $frame['line']]; + } + + return ['file' => __FILE__, 'line' => __LINE__]; + } + + /** + * @param array{file?: string, line?: int, class?: string} $frame + */ + private function isPipelineFrame(array $frame, string $file): bool + { + $class = $frame['class'] ?? ''; + if ( + str_starts_with($class, 'Yiisoft\\Queue\\Middleware\\') + || $class === AsyncQueueProducer::class + || $class === SyncQueueProducer::class + ) { + return true; + } + + return in_array( + basename($file), + [ + 'PushDebugMiddleware.php', + 'PushMiddlewareDispatcher.php', + 'PushMiddlewareStack.php', + 'AsyncQueueProducer.php', + 'SyncQueueProducer.php', + ], + true, + ); + } +} diff --git a/src/Debug/Middleware/WorkerDebugMiddleware.php b/src/Debug/Middleware/WorkerDebugMiddleware.php new file mode 100644 index 00000000..5d6d59c9 --- /dev/null +++ b/src/Debug/Middleware/WorkerDebugMiddleware.php @@ -0,0 +1,21 @@ +collector->collectWorkerProcessing($request->getMessage(), $request->getQueueName()); + return $handler->handleWorker($request); + } +} diff --git a/src/Debug/QueueConsumerDecorator.php b/src/Debug/QueueConsumerDecorator.php deleted file mode 100644 index 03670b45..00000000 --- a/src/Debug/QueueConsumerDecorator.php +++ /dev/null @@ -1,22 +0,0 @@ -queue->run($max); - } - - public function listen(): void - { - $this->queue->listen(); - } -} diff --git a/src/Debug/QueueConsumerProviderProxy.php b/src/Debug/QueueConsumerProviderProxy.php deleted file mode 100644 index ab6a6e28..00000000 --- a/src/Debug/QueueConsumerProviderProxy.php +++ /dev/null @@ -1,29 +0,0 @@ -provider->getConsumer($queueName), $this->collector); - } - - public function hasConsumer(string|BackedEnum $queueName): bool - { - return $this->provider->hasConsumer($queueName); - } - - public function getConsumerQueueNames(): array - { - return $this->provider->getConsumerQueueNames(); - } -} diff --git a/src/Debug/QueueProducerDecorator.php b/src/Debug/QueueProducerDecorator.php deleted file mode 100644 index 589ba1c1..00000000 --- a/src/Debug/QueueProducerDecorator.php +++ /dev/null @@ -1,33 +0,0 @@ -queue->status($id); - $this->collector->collectStatus((string) $id, $result, $stack['file'] . ':' . $stack['line']); - return $result; - } - - public function push(MessageInterface $message): MessageInterface - { /** @psalm-var array{file: string, line: int} $stack */ $stack = debug_backtrace()[0]; - $message = $this->queue->push($message); - $this->collector->collectPush($this->queue->getQueueName(), $message, $stack['file'] . ':' . $stack['line']); - return $message; - } - - public function getQueueName(): string - { - return $this->queue->getQueueName(); - } -} diff --git a/src/Debug/QueueProducerProviderProxy.php b/src/Debug/QueueProducerProviderProxy.php deleted file mode 100644 index a71f846a..00000000 --- a/src/Debug/QueueProducerProviderProxy.php +++ /dev/null @@ -1,29 +0,0 @@ -provider->getProducer($queueName), $this->collector); - } - - public function hasProducer(string|BackedEnum $queueName): bool - { - return $this->provider->hasProducer($queueName); - } - - public function getProducerQueueNames(): array - { - return $this->provider->getProducerQueueNames(); - } -} diff --git a/src/Debug/QueueProducerStatusProviderProxy.php b/src/Debug/QueueProducerStatusProviderProxy.php new file mode 100644 index 00000000..59d6e5f1 --- /dev/null +++ b/src/Debug/QueueProducerStatusProviderProxy.php @@ -0,0 +1,36 @@ +provider->getStatus(StringNormalizer::normalize($queueName)), + $this->collector, + ); + } + + public function hasStatus(string|BackedEnum $queueName): bool + { + return $this->provider->hasStatus(StringNormalizer::normalize($queueName)); + } + + public function getStatusQueueNames(): array + { + return $this->provider->getStatusQueueNames(); + } +} diff --git a/src/Debug/QueueProducerStatusProxy.php b/src/Debug/QueueProducerStatusProxy.php new file mode 100644 index 00000000..f5c873cd --- /dev/null +++ b/src/Debug/QueueProducerStatusProxy.php @@ -0,0 +1,25 @@ +status->status($id); + $this->collector->collectStatus((string) $id, $result, $stack['file'] . ':' . $stack['line']); + return $result; + } +} diff --git a/src/Debug/QueueWorkerInterfaceProxy.php b/src/Debug/QueueWorkerInterfaceProxy.php deleted file mode 100644 index b26c5cd5..00000000 --- a/src/Debug/QueueWorkerInterfaceProxy.php +++ /dev/null @@ -1,26 +0,0 @@ -collector->collectWorkerProcessing($message, $queueName); - return $this->worker->process($message, $queueName, $retryProducer); - } -} diff --git a/src/Middleware/FailureHandling/FailureHandlingRequest.php b/src/Middleware/FailureHandling/FailureHandlingRequest.php index 03dc9888..9dc79b32 100644 --- a/src/Middleware/FailureHandling/FailureHandlingRequest.php +++ b/src/Middleware/FailureHandling/FailureHandlingRequest.php @@ -4,18 +4,25 @@ namespace Yiisoft\Queue\Middleware\FailureHandling; +use Closure; use Throwable; +use BackedEnum; use Yiisoft\Queue\Message\MessageInterface; -use Yiisoft\Queue\QueueProducerInterface; +use Yiisoft\Queue\StringNormalizer; final class FailureHandlingRequest { + private readonly string $queueName; + + /** @param Closure(MessageInterface): MessageInterface $retry */ public function __construct( - private MessageInterface $message, - private Throwable $exception, - private string $queueName, - private ?QueueProducerInterface $retryProducer = null, - ) {} + private readonly MessageInterface $message, + private readonly Throwable $exception, + string|BackedEnum $queueName, + private readonly ?Closure $retry = null, + ) { + $this->queueName = StringNormalizer::normalize($queueName); + } public function getMessage(): MessageInterface { @@ -27,36 +34,24 @@ public function getException(): Throwable return $this->exception; } - /** Logical name of the queue which executed the message. */ public function getQueueName(): string { return $this->queueName; } - /** Direct retry target used by synchronous producer execution, if any. */ - public function getRetryProducer(): ?QueueProducerInterface + /** @return Closure(MessageInterface): MessageInterface */ + public function getRetry(): ?Closure { - return $this->retryProducer; + return $this->retry; } public function withMessage(MessageInterface $message): self { - $instance = clone $this; - $instance->message = $message; - return $instance; + return new self($message, $this->exception, $this->queueName, $this->retry); } public function withException(Throwable $exception): self { - $instance = clone $this; - $instance->exception = $exception; - return $instance; - } - - public function withQueueName(string $queueName): self - { - $instance = clone $this; - $instance->queueName = $queueName; - return $instance; + return new self($this->message, $exception, $this->queueName, $this->retry); } } diff --git a/src/Middleware/FailureHandling/Implementation/ExponentialDelayMiddleware.php b/src/Middleware/FailureHandling/Implementation/ExponentialDelayMiddleware.php index 524461c6..3a0a1f4a 100644 --- a/src/Middleware/FailureHandling/Implementation/ExponentialDelayMiddleware.php +++ b/src/Middleware/FailureHandling/Implementation/ExponentialDelayMiddleware.php @@ -4,6 +4,7 @@ namespace Yiisoft\Queue\Middleware\FailureHandling\Implementation; +use Closure; use InvalidArgumentException; use Yiisoft\Queue\Message\DelayEnvelope; use Yiisoft\Queue\Message\MessageInterface; @@ -13,7 +14,6 @@ use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareInterface; use Yiisoft\Queue\Provider\InvalidQueueConfigException; use Yiisoft\Queue\Provider\QueueProducerProviderInterface; -use Yiisoft\Queue\QueueProducerInterface; use Throwable; use function sprintf; @@ -24,13 +24,16 @@ final class ExponentialDelayMiddleware implements FailureMiddlewareInterface public const META_KEY_ATTEMPTS = 'failure-strategy-exponential-delay-attempts'; public const META_KEY_DELAY = 'failure-strategy-exponential-delay-delay'; + /** + * @param Closure(MessageInterface): MessageInterface $queue + */ public function __construct( private readonly string $id, private readonly int $maxAttempts, private readonly float $delayInitial, private readonly float $delayMaximum, private readonly float $exponent, - private readonly ?QueueProducerInterface $queue = null, + private readonly ?Closure $queue = null, private readonly ?QueueProducerProviderInterface $producerProvider = null, ) { if ($maxAttempts <= 0) { @@ -54,23 +57,20 @@ public function processFailure(FailureHandlingRequest $request, FailureHandlerIn return $handler->handleFailure($request); } $failure = new FailureEnvelope($message, $this->createNewMeta($message)); - $result = $this->producer($request)->push(new DelayEnvelope($failure, $this->getDelay($failure))); - return $request->withMessage($result); + $delayed = new DelayEnvelope($failure, $this->getDelay($failure)); + $retry = $this->queue ?? $request->getRetry() ?? $this->sourceProducer($request); + return $request->withMessage($retry($delayed)); } - private function producer(FailureHandlingRequest $request): QueueProducerInterface + /** @return Closure(MessageInterface): MessageInterface */ + private function sourceProducer(FailureHandlingRequest $request): Closure { - if ($this->queue !== null) { - return $this->queue; - } - if ($request->getRetryProducer() !== null) { - return $request->getRetryProducer(); - } if ($this->producerProvider === null) { throw new InvalidQueueConfigException(sprintf('Cannot retry queue "%s": configure a producer target or QueueProducerProviderInterface.', $request->getQueueName())); } try { - return $this->producerProvider->getProducer($request->getQueueName()); + $producer = $this->producerProvider->getProducer($request->getQueueName()); + return static fn(MessageInterface $message): MessageInterface => $producer->push($message); } catch (Throwable $exception) { throw new InvalidQueueConfigException(sprintf('Cannot retry queue "%s": no producer capability is available.', $request->getQueueName()), previous: $exception); } diff --git a/src/Middleware/FailureHandling/Implementation/SendAgainMiddleware.php b/src/Middleware/FailureHandling/Implementation/SendAgainMiddleware.php index 1de2074d..3a7a7793 100644 --- a/src/Middleware/FailureHandling/Implementation/SendAgainMiddleware.php +++ b/src/Middleware/FailureHandling/Implementation/SendAgainMiddleware.php @@ -4,6 +4,7 @@ namespace Yiisoft\Queue\Middleware\FailureHandling\Implementation; +use Closure; use InvalidArgumentException; use Yiisoft\Queue\Message\MessageInterface; use Yiisoft\Queue\Middleware\FailureHandling\FailureEnvelope; @@ -12,7 +13,6 @@ use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareInterface; use Yiisoft\Queue\Provider\InvalidQueueConfigException; use Yiisoft\Queue\Provider\QueueProducerProviderInterface; -use Yiisoft\Queue\QueueProducerInterface; use Throwable; use function sprintf; @@ -22,10 +22,13 @@ final class SendAgainMiddleware implements FailureMiddlewareInterface { public const META_KEY_RESEND = 'failure-strategy-resend-attempts'; + /** + * @param Closure(MessageInterface): MessageInterface $targetQueue + */ public function __construct( private readonly string $id, private readonly int $maxAttempts, - private readonly ?QueueProducerInterface $targetQueue = null, + private readonly ?Closure $targetQueue = null, private readonly ?QueueProducerProviderInterface $producerProvider = null, ) { if ($maxAttempts < 1) { @@ -40,18 +43,19 @@ public function processFailure(FailureHandlingRequest $request, FailureHandlerIn return $handler->handleFailure($request); } $envelope = new FailureEnvelope($message, [$this->getMetaKey() => $this->getAttempts($message) + 1]); - $producer = $this->targetQueue ?? $request->getRetryProducer() ?? $this->sourceProducer($request); - $envelope = $producer->push($envelope); - return $request->withMessage($envelope); + $retry = $this->targetQueue ?? $request->getRetry() ?? $this->sourceProducer($request); + return $request->withMessage($retry($envelope)); } - private function sourceProducer(FailureHandlingRequest $request): QueueProducerInterface + /** @return Closure(MessageInterface): MessageInterface */ + private function sourceProducer(FailureHandlingRequest $request): Closure { if ($this->producerProvider === null) { throw new InvalidQueueConfigException(sprintf('Cannot retry queue "%s": configure a producer target or QueueProducerProviderInterface.', $request->getQueueName())); } try { - return $this->producerProvider->getProducer($request->getQueueName()); + $producer = $this->producerProvider->getProducer($request->getQueueName()); + return static fn(MessageInterface $message): MessageInterface => $producer->push($message); } catch (Throwable $exception) { throw new InvalidQueueConfigException(sprintf('Cannot retry queue "%s": no producer capability is available.', $request->getQueueName()), previous: $exception); } diff --git a/src/Middleware/Push/AdapterPushHandler.php b/src/Middleware/Push/AdapterPushHandler.php index 20e35c7d..c6befb60 100644 --- a/src/Middleware/Push/AdapterPushHandler.php +++ b/src/Middleware/Push/AdapterPushHandler.php @@ -5,7 +5,6 @@ namespace Yiisoft\Queue\Middleware\Push; use Yiisoft\Queue\Adapter\AdapterInterface; -use Yiisoft\Queue\Message\MessageInterface; /** * @internal @@ -16,8 +15,8 @@ public function __construct( private readonly AdapterInterface $adapter, ) {} - public function handlePush(MessageInterface $message): MessageInterface + public function handlePush(PushRequest $request): PushRequest { - return $this->adapter->push($message); + return $request->withMessage($this->adapter->push($request->getMessage())); } } diff --git a/src/Middleware/Push/Implementation/IdMiddleware.php b/src/Middleware/Push/Implementation/IdMiddleware.php index d2ec2482..da0c36da 100644 --- a/src/Middleware/Push/Implementation/IdMiddleware.php +++ b/src/Middleware/Push/Implementation/IdMiddleware.php @@ -5,7 +5,7 @@ namespace Yiisoft\Queue\Middleware\Push\Implementation; use Yiisoft\Queue\Message\IdEnvelope; -use Yiisoft\Queue\Message\MessageInterface; +use Yiisoft\Queue\Middleware\Push\PushRequest; use Yiisoft\Queue\Middleware\Push\PushHandlerInterface; use Yiisoft\Queue\Middleware\Push\PushMiddlewareInterface; @@ -14,16 +14,17 @@ */ final class IdMiddleware implements PushMiddlewareInterface { - public function processPush(MessageInterface $message, PushHandlerInterface $handler): MessageInterface + public function processPush(PushRequest $request, PushHandlerInterface $handler): PushRequest { + $message = $request->getMessage(); $envelope = IdEnvelope::fromMessage($message); if ($envelope->getId() === null) { return $handler->handlePush( - new IdEnvelope($message, uniqid('yii3-message-', true)), + $request->withMessage(new IdEnvelope($message, uniqid('yii3-message-', true))), ); } - return $handler->handlePush($message); + return $handler->handlePush($request); } } diff --git a/src/Middleware/Push/PushHandlerInterface.php b/src/Middleware/Push/PushHandlerInterface.php index 3f048123..0e7043c2 100644 --- a/src/Middleware/Push/PushHandlerInterface.php +++ b/src/Middleware/Push/PushHandlerInterface.php @@ -4,9 +4,7 @@ namespace Yiisoft\Queue\Middleware\Push; -use Yiisoft\Queue\Message\MessageInterface; - interface PushHandlerInterface { - public function handlePush(MessageInterface $message): MessageInterface; + public function handlePush(PushRequest $request): PushRequest; } diff --git a/src/Middleware/Push/PushMiddlewareDispatcher.php b/src/Middleware/Push/PushMiddlewareDispatcher.php index 54e3abb5..1c94f8a4 100644 --- a/src/Middleware/Push/PushMiddlewareDispatcher.php +++ b/src/Middleware/Push/PushMiddlewareDispatcher.php @@ -5,7 +5,6 @@ namespace Yiisoft\Queue\Middleware\Push; use Closure; -use Yiisoft\Queue\Message\MessageInterface; /** * @internal Used internally by {@see SyncQueueProducer} and {@see AsyncQueueProducer}. @@ -33,13 +32,13 @@ public function __construct( /** * Dispatch message through middleware to get response. * - * @param MessageInterface $message Message to pass to middleware. + * @param PushRequest $request Request to pass to middleware. */ - public function dispatch(MessageInterface $message): MessageInterface + public function dispatch(PushRequest $request): PushRequest { $this->stack ??= new PushMiddlewareStack($this->buildMiddlewares(), $this->finishHandler); - return $this->stack->handlePush($message); + return $this->stack->handlePush($request); } public function withFinishHandler(PushHandlerInterface $finishHandler): self diff --git a/src/Middleware/Push/PushMiddlewareFactory.php b/src/Middleware/Push/PushMiddlewareFactory.php index de70f488..98adb786 100644 --- a/src/Middleware/Push/PushMiddlewareFactory.php +++ b/src/Middleware/Push/PushMiddlewareFactory.php @@ -4,7 +4,6 @@ namespace Yiisoft\Queue\Middleware\Push; -use Yiisoft\Queue\Message\MessageInterface; use Yiisoft\Queue\Middleware\InvalidMiddlewareDefinitionException; use Yiisoft\Queue\Middleware\MiddlewareFactory; @@ -25,15 +24,14 @@ final class PushMiddlewareFactory extends MiddlewareFactory implements PushMiddl * * - A middleware object. * - A name of a middleware class. The middleware instance will be obtained from container and executed. - * - A callable with `function(MessageInterface $message, PushHandlerInterface $handler): - * MessageInterface` signature. + * - A callable with `function(PushRequest $request, PushHandlerInterface $handler): PushRequest` signature. * - A controller handler action in format `[TestController::class, 'index']`. `TestController` instance will * be created and `index()` method will be executed. * - A function returning a middleware. The middleware returned will be executed. * * For handler action and callable * typed parameters are automatically injected using dependency injection container. - * Current message and handler could be obtained by type-hinting for {@see MessageInterface} + * Current request and handler could be obtained by type-hinting for {@see PushRequest} * and {@see PushHandlerInterface}. * * @throws InvalidMiddlewareDefinitionException @@ -74,15 +72,15 @@ public function __construct(callable $callback) $this->callback = $callback; } - public function processPush(MessageInterface $message, PushHandlerInterface $handler): MessageInterface + public function processPush(PushRequest $request, PushHandlerInterface $handler): PushRequest { - $response = ($this->callback)($message, $handler); - if ($response instanceof MessageInterface) { + $response = ($this->callback)($request, $handler); + if ($response instanceof PushRequest) { return $response; } if ($response instanceof PushMiddlewareInterface) { - return $response->processPush($message, $handler); + return $response->processPush($request, $handler); } throw new InvalidMiddlewareDefinitionException($this->callback); diff --git a/src/Middleware/Push/PushMiddlewareInterface.php b/src/Middleware/Push/PushMiddlewareInterface.php index 290be84b..8002ab24 100644 --- a/src/Middleware/Push/PushMiddlewareInterface.php +++ b/src/Middleware/Push/PushMiddlewareInterface.php @@ -4,9 +4,7 @@ namespace Yiisoft\Queue\Middleware\Push; -use Yiisoft\Queue\Message\MessageInterface; - interface PushMiddlewareInterface { - public function processPush(MessageInterface $message, PushHandlerInterface $handler): MessageInterface; + public function processPush(PushRequest $request, PushHandlerInterface $handler): PushRequest; } diff --git a/src/Middleware/Push/PushMiddlewareStack.php b/src/Middleware/Push/PushMiddlewareStack.php index 4ea47696..994caf86 100644 --- a/src/Middleware/Push/PushMiddlewareStack.php +++ b/src/Middleware/Push/PushMiddlewareStack.php @@ -5,7 +5,6 @@ namespace Yiisoft\Queue\Middleware\Push; use Closure; -use Yiisoft\Queue\Message\MessageInterface; /** * @internal @@ -31,10 +30,10 @@ public function __construct( private readonly PushHandlerInterface $finishHandler, ) {} - public function handlePush(MessageInterface $message): MessageInterface + public function handlePush(PushRequest $request): PushRequest { $this->stack ??= $this->build(); - return $this->stack->handlePush($message); + return $this->stack->handlePush($request); } private function build(): PushHandlerInterface @@ -66,11 +65,11 @@ public function __construct( private readonly PushHandlerInterface $handler, ) {} - public function handlePush(MessageInterface $message): MessageInterface + public function handlePush(PushRequest $request): PushRequest { $this->middleware ??= ($this->middlewareFactory)(); - return $this->middleware->processPush($message, $this->handler); + return $this->middleware->processPush($request, $this->handler); } }; } diff --git a/src/Middleware/Push/PushRequest.php b/src/Middleware/Push/PushRequest.php new file mode 100644 index 00000000..2cef0ed1 --- /dev/null +++ b/src/Middleware/Push/PushRequest.php @@ -0,0 +1,36 @@ +queueName = StringNormalizer::normalize($queueName); + } + + public function getQueueName(): string + { + return $this->queueName; + } + + public function getMessage(): MessageInterface + { + return $this->message; + } + + public function withMessage(MessageInterface $message): self + { + return new self($message, $this->queueName); + } +} diff --git a/src/Middleware/Push/SynchronousPushHandler.php b/src/Middleware/Push/SynchronousPushHandler.php index db7b9cfc..84ea3dcf 100644 --- a/src/Middleware/Push/SynchronousPushHandler.php +++ b/src/Middleware/Push/SynchronousPushHandler.php @@ -4,9 +4,9 @@ namespace Yiisoft\Queue\Middleware\Push; +use Yiisoft\Queue\Worker\Worker; +use Yiisoft\Queue\SyncQueueProducer; use Yiisoft\Queue\Message\MessageInterface; -use Yiisoft\Queue\QueueProducerInterface; -use Yiisoft\Queue\Worker\WorkerInterface; /** * @internal @@ -14,14 +14,16 @@ final class SynchronousPushHandler implements PushHandlerInterface { public function __construct( - private readonly WorkerInterface $worker, - private readonly QueueProducerInterface $queue, + private readonly Worker $worker, + private readonly SyncQueueProducer $queue, ) {} - public function handlePush(MessageInterface $message): MessageInterface + public function handlePush(PushRequest $request): PushRequest { - $this->worker->process($message, $this->queue->getQueueName(), $this->queue); - - return $message; + return $request->withMessage($this->worker->process( + $request->getMessage(), + $request->getQueueName(), + fn(MessageInterface $message): MessageInterface => $this->queue->push($message), + )); } } diff --git a/src/Middleware/Worker/WorkerFinalHandler.php b/src/Middleware/Worker/WorkerFinalHandler.php new file mode 100644 index 00000000..e0f20b25 --- /dev/null +++ b/src/Middleware/Worker/WorkerFinalHandler.php @@ -0,0 +1,65 @@ +withMessage($this->process( + $request->getMessage(), + $request->getQueueName(), + $request->getRetry(), + )); + } + + /** @param null|Closure(MessageInterface): MessageInterface $retry */ + public function process(MessageInterface $message, string $queueName, ?Closure $retry = null): MessageInterface + { + $id = IdEnvelope::fromMessage($message)->getId(); + $id === null + ? $this->logger->info('Processing message without ID.') + : $this->logger->info('Processing message #{message}.', ['message' => $id]); + + try { + $handler = $this->resolver->resolve($message->getType()); + $consumeRequest = new ConsumeRequest($message, $queueName); + $result = $this->consume->dispatch($consumeRequest, new ConsumeFinalHandler($handler->handle(...))); + return $result->getMessage(); + } catch (Throwable $exception) { + $failureRequest = new FailureHandlingRequest($message, $exception, $queueName, $retry); + try { + $result = $this->failure->dispatch($failureRequest, new FailureFinalHandler()); + $this->logger->info($exception->getMessage()); + return $result->getMessage(); + } catch (Throwable $failureException) { + $failureException = new MessageFailureException($message, $failureException); + $this->logger->error($failureException->getMessage()); + throw $failureException; + } + } + } +} diff --git a/src/Middleware/Worker/WorkerHandlerInterface.php b/src/Middleware/Worker/WorkerHandlerInterface.php new file mode 100644 index 00000000..74ead4f6 --- /dev/null +++ b/src/Middleware/Worker/WorkerHandlerInterface.php @@ -0,0 +1,10 @@ +stack ??= new WorkerMiddlewareStack($this->buildMiddlewares(), $finishHandler); + + return $this->stack->handleWorker($request, $finishHandler); + } + + /** + * Returns a new instance with middleware definitions replaced. + * + * @param mixed[] $middlewareDefinitions Middleware definitions in execution order. + */ + public function withMiddlewares(array $middlewareDefinitions): self + { + $instance = clone $this; + $instance->middlewareDefinitions = $middlewareDefinitions; + + // Fixes a memory leak. + unset($instance->stack); + $instance->stack = null; + + return $instance; + } + + public function hasMiddlewares(): bool + { + return $this->middlewareDefinitions !== []; + } + + /** + * @psalm-return list + */ + private function buildMiddlewares(): array + { + $middlewares = []; + $factory = $this->middlewareFactory; + + foreach ($this->middlewareDefinitions as $middlewareDefinition) { + $middlewares[] = static fn(): WorkerMiddlewareInterface => $factory->createWorkerMiddleware( + $middlewareDefinition, + ); + } + + return $middlewares; + } +} diff --git a/src/Middleware/Worker/WorkerMiddlewareFactory.php b/src/Middleware/Worker/WorkerMiddlewareFactory.php new file mode 100644 index 00000000..e9e5ff26 --- /dev/null +++ b/src/Middleware/Worker/WorkerMiddlewareFactory.php @@ -0,0 +1,76 @@ + + */ +final class WorkerMiddlewareFactory extends MiddlewareFactory implements WorkerMiddlewareFactoryInterface +{ + /** + * @param mixed $definition + * @throws InvalidMiddlewareDefinitionException + */ + public function createWorkerMiddleware(mixed $definition): WorkerMiddlewareInterface + { + if ($definition instanceof WorkerMiddlewareInterface) { + return $definition; + } + + if (!is_callable($definition) && !is_array($definition) && !is_string($definition)) { + throw new InvalidMiddlewareDefinitionException($definition); + } + + /** @var callable|array|string $definition */ + $middleware = $this->create($definition); + + if (!$middleware instanceof WorkerMiddlewareInterface) { + throw new InvalidMiddlewareDefinitionException($definition); + } + + return $middleware; + } + + protected function getInterfaceName(): string + { + return WorkerMiddlewareInterface::class; + } + + protected function wrapMiddleware(callable $callback): WorkerMiddlewareInterface + { + return new class ($callback) implements WorkerMiddlewareInterface { + /** @var callable */ + private readonly mixed $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function processWorker(WorkerRequest $request, WorkerHandlerInterface $handler): WorkerRequest + { + $response = ($this->callback)($request, $handler); + if ($response instanceof WorkerRequest) { + return $response; + } + + if ($response instanceof WorkerMiddlewareInterface) { + return $response->processWorker($request, $handler); + } + + throw new InvalidMiddlewareDefinitionException($this->callback); + } + }; + } +} diff --git a/src/Middleware/Worker/WorkerMiddlewareFactoryInterface.php b/src/Middleware/Worker/WorkerMiddlewareFactoryInterface.php new file mode 100644 index 00000000..ab50be64 --- /dev/null +++ b/src/Middleware/Worker/WorkerMiddlewareFactoryInterface.php @@ -0,0 +1,10 @@ + $middlewares + */ + public function __construct( + private readonly array $middlewares, + private WorkerHandlerInterface $finishHandler, + ) {} + + public function handleWorker(WorkerRequest $request, ?WorkerHandlerInterface $finishHandler = null): WorkerRequest + { + if ($finishHandler !== null) { + $this->finishHandler = $finishHandler; + } + $this->stack ??= $this->build(); + + return $this->stack->handleWorker($request); + } + + public function handleFinish(WorkerRequest $request): WorkerRequest + { + return $this->finishHandler->handleWorker($request); + } + + private function build(): WorkerHandlerInterface + { + $handler = new class ($this) implements WorkerHandlerInterface { + public function __construct(private readonly WorkerMiddlewareStack $stack) {} + + public function handleWorker(WorkerRequest $request): WorkerRequest + { + return $this->stack->handleFinish($request); + } + }; + + foreach (array_reverse($this->middlewares) as $middleware) { + $handler = $this->wrap($middleware, $handler); + } + + return $handler; + } + + /** + * @psalm-param Closure():WorkerMiddlewareInterface $middlewareFactory + */ + private function wrap(Closure $middlewareFactory, WorkerHandlerInterface $handler): WorkerHandlerInterface + { + return new class ($middlewareFactory, $handler) implements WorkerHandlerInterface { + private ?WorkerMiddlewareInterface $middleware = null; + + /** + * @psalm-param Closure():WorkerMiddlewareInterface $middlewareFactory + */ + public function __construct( + private readonly Closure $middlewareFactory, + private readonly WorkerHandlerInterface $handler, + ) {} + + public function handleWorker(WorkerRequest $request): WorkerRequest + { + $this->middleware ??= ($this->middlewareFactory)(); + + return $this->middleware->processWorker($request, $this->handler); + } + }; + } +} diff --git a/src/Middleware/Worker/WorkerRequest.php b/src/Middleware/Worker/WorkerRequest.php new file mode 100644 index 00000000..5d892e3e --- /dev/null +++ b/src/Middleware/Worker/WorkerRequest.php @@ -0,0 +1,45 @@ +queueName = StringNormalizer::normalize($queueName); + } + + public function getMessage(): MessageInterface + { + return $this->message; + } + + public function getQueueName(): string + { + return $this->queueName; + } + + /** @return Closure(MessageInterface): MessageInterface */ + public function getRetry(): ?Closure + { + return $this->retry; + } + + public function withMessage(MessageInterface $message): self + { + return new self($message, $this->queueName, $this->retry); + } +} diff --git a/src/NotFoundQueueProducerStatus.php b/src/NotFoundQueueProducerStatus.php new file mode 100644 index 00000000..48fda46a --- /dev/null +++ b/src/NotFoundQueueProducerStatus.php @@ -0,0 +1,13 @@ +producerProviders as $provider) { if ($provider->hasProducer($queueName)) { @@ -61,7 +62,7 @@ public function getProducerQueueNames(): array } return $result; } - public function getConsumer(string|BackedEnum $queueName): QueueConsumerInterface + public function getConsumer(string|BackedEnum $queueName): QueueConsumer { foreach ($this->consumerProviders as $provider) { if ($provider->hasConsumer($queueName)) { diff --git a/src/Provider/PredefinedQueueProvider.php b/src/Provider/PredefinedQueueProvider.php index 7b3d6df2..038047a8 100644 --- a/src/Provider/PredefinedQueueProvider.php +++ b/src/Provider/PredefinedQueueProvider.php @@ -5,8 +5,9 @@ namespace Yiisoft\Queue\Provider; use BackedEnum; -use Yiisoft\Queue\QueueConsumerInterface; -use Yiisoft\Queue\QueueProducerInterface; +use Yiisoft\Queue\AsyncQueueProducer; +use Yiisoft\Queue\QueueConsumer; +use Yiisoft\Queue\SyncQueueProducer; use Yiisoft\Queue\StringNormalizer; use function array_key_exists; @@ -21,7 +22,7 @@ /** Provides already-created producer and consumer instances from strict role maps. */ final class PredefinedQueueProvider implements QueueProducerProviderInterface, QueueConsumerProviderInterface { - /** @var array> */ + /** @var array> */ private array $queues = []; /** @var list */ private array $producerQueueNames = []; @@ -40,8 +41,8 @@ public function __construct(array $queues) throw new InvalidQueueConfigException(sprintf('Queue "%s" has unknown role key(s) "%s". Only "producer" and "consumer" are allowed.', $queueName, implode('", "', $unknown))); } foreach ($roles as $role => $instance) { - $expected = $role === 'producer' ? QueueProducerInterface::class : QueueConsumerInterface::class; - if (!$instance instanceof $expected) { + $expected = $role === 'producer' ? AsyncQueueProducer::class . ' or ' . SyncQueueProducer::class : QueueConsumer::class; + if ($role === 'producer' ? (!$instance instanceof AsyncQueueProducer && !$instance instanceof SyncQueueProducer) : !$instance instanceof QueueConsumer) { $hint = is_array($instance) || is_string($instance) ? ' Use QueueFactoryProvider for factory definitions.' : ''; throw new InvalidQueueConfigException(sprintf( 'Queue "%s" role "%s" must be a ready instance of "%s"; got "%s" (configuration path queues.%s.%s).%s', @@ -55,7 +56,7 @@ public function __construct(array $queues) )); } } - /** @var array $roles */ + /** @var array $roles */ $this->queues[$queueName] = $roles; if (array_key_exists('producer', $roles)) { $this->producerQueueNames[] = $queueName; @@ -66,10 +67,10 @@ public function __construct(array $queues) } } - public function getProducer(string|BackedEnum $queueName): QueueProducerInterface + public function getProducer(string|BackedEnum $queueName): AsyncQueueProducer|SyncQueueProducer { $instance = $this->get($queueName, 'producer'); - assert($instance instanceof QueueProducerInterface); + assert($instance instanceof AsyncQueueProducer || $instance instanceof SyncQueueProducer); return $instance; } @@ -83,10 +84,10 @@ public function getProducerQueueNames(): array return $this->producerQueueNames; } - public function getConsumer(string|BackedEnum $queueName): QueueConsumerInterface + public function getConsumer(string|BackedEnum $queueName): QueueConsumer { $instance = $this->get($queueName, 'consumer'); - assert($instance instanceof QueueConsumerInterface); + assert($instance instanceof QueueConsumer); return $instance; } @@ -100,7 +101,7 @@ public function getConsumerQueueNames(): array return $this->consumerQueueNames; } - private function get(string|BackedEnum $queueName, string $role): QueueProducerInterface|QueueConsumerInterface + private function get(string|BackedEnum $queueName, string $role): AsyncQueueProducer|SyncQueueProducer|QueueConsumer { $queueName = StringNormalizer::normalize($queueName); if (!array_key_exists($queueName, $this->queues)) { diff --git a/src/Provider/QueueConsumerProviderInterface.php b/src/Provider/QueueConsumerProviderInterface.php index e114f68d..df3d202c 100644 --- a/src/Provider/QueueConsumerProviderInterface.php +++ b/src/Provider/QueueConsumerProviderInterface.php @@ -5,13 +5,13 @@ namespace Yiisoft\Queue\Provider; use BackedEnum; -use Yiisoft\Queue\QueueConsumerInterface; +use Yiisoft\Queue\QueueConsumer; /** Finds consumer capabilities by logical queue name. */ interface QueueConsumerProviderInterface { /** @throws InvalidQueueConfigException|QueueNotFoundException|QueueProviderException */ - public function getConsumer(string|BackedEnum $queueName): QueueConsumerInterface; + public function getConsumer(string|BackedEnum $queueName): QueueConsumer; /** Whether this queue name has a configured consumer role. */ public function hasConsumer(string|BackedEnum $queueName): bool; diff --git a/src/Provider/QueueFactoryProvider.php b/src/Provider/QueueFactoryProvider.php index 5957815a..9df60506 100644 --- a/src/Provider/QueueFactoryProvider.php +++ b/src/Provider/QueueFactoryProvider.php @@ -9,8 +9,9 @@ use Throwable; use Yiisoft\Definitions\Exception\InvalidConfigException; use Yiisoft\Factory\StrictFactory; -use Yiisoft\Queue\QueueConsumerInterface; -use Yiisoft\Queue\QueueProducerInterface; +use Yiisoft\Queue\AsyncQueueProducer; +use Yiisoft\Queue\QueueConsumer; +use Yiisoft\Queue\SyncQueueProducer; use Yiisoft\Queue\StringNormalizer; use function array_key_exists; @@ -26,7 +27,7 @@ final class QueueFactoryProvider implements QueueProducerProviderInterface, Queu { /** @var array> */ private array $definitions; - /** @var array> */ + /** @var array> */ private array $resolved = []; /** @var list */ private array $producerQueueNames = []; @@ -52,10 +53,10 @@ public function __construct( } } - public function getProducer(string|BackedEnum $queueName): QueueProducerInterface + public function getProducer(string|BackedEnum $queueName): AsyncQueueProducer|SyncQueueProducer { - $producer = $this->get($queueName, 'producer', QueueProducerInterface::class); - assert($producer instanceof QueueProducerInterface); + $producer = $this->get($queueName, 'producer'); + assert($producer instanceof AsyncQueueProducer || $producer instanceof SyncQueueProducer); return $producer; } @@ -69,10 +70,10 @@ public function getProducerQueueNames(): array return $this->producerQueueNames; } - public function getConsumer(string|BackedEnum $queueName): QueueConsumerInterface + public function getConsumer(string|BackedEnum $queueName): QueueConsumer { - $consumer = $this->get($queueName, 'consumer', QueueConsumerInterface::class); - assert($consumer instanceof QueueConsumerInterface); + $consumer = $this->get($queueName, 'consumer'); + assert($consumer instanceof QueueConsumer); return $consumer; } @@ -86,8 +87,7 @@ public function getConsumerQueueNames(): array return $this->consumerQueueNames; } - /** @template T of QueueProducerInterface|QueueConsumerInterface @param class-string $expected @return T */ - private function get(string|BackedEnum $queueName, string $role, string $expected): QueueProducerInterface|QueueConsumerInterface + private function get(string|BackedEnum $queueName, string $role): AsyncQueueProducer|SyncQueueProducer|QueueConsumer { $queueName = StringNormalizer::normalize($queueName); if (!array_key_exists($queueName, $this->definitions)) { @@ -107,18 +107,18 @@ private function get(string|BackedEnum $queueName, string $role, string $expecte $key = $queueName . ':' . $role; $factory = new StrictFactory([$key => $this->definitions[$queueName][$role]], $this->container, $this->validate); $result = $factory->create($key); - if (!$result instanceof $expected) { + if ($role === 'producer' ? (!$result instanceof AsyncQueueProducer && !$result instanceof SyncQueueProducer) : !$result instanceof QueueConsumer) { throw new InvalidQueueConfigException(sprintf( 'Queue "%s" role "%s" must implement "%s"; got "%s" (configuration path queues.%s.%s).', $queueName, $role, - $expected, + $role === 'producer' ? AsyncQueueProducer::class . ' or ' . SyncQueueProducer::class : QueueConsumer::class, get_debug_type($result), $queueName, $role, )); } - assert($result instanceof QueueProducerInterface || $result instanceof QueueConsumerInterface); + assert($result instanceof AsyncQueueProducer || $result instanceof SyncQueueProducer || $result instanceof QueueConsumer); $this->resolved[$queueName][$role] = $result; return $result; } catch (InvalidQueueConfigException $exception) { diff --git a/src/Provider/QueueProducerProviderInterface.php b/src/Provider/QueueProducerProviderInterface.php index 8d8073ea..31a421bb 100644 --- a/src/Provider/QueueProducerProviderInterface.php +++ b/src/Provider/QueueProducerProviderInterface.php @@ -5,13 +5,14 @@ namespace Yiisoft\Queue\Provider; use BackedEnum; -use Yiisoft\Queue\QueueProducerInterface; +use Yiisoft\Queue\AsyncQueueProducer; +use Yiisoft\Queue\SyncQueueProducer; /** Finds producer capabilities by logical queue name. */ interface QueueProducerProviderInterface { /** @throws InvalidQueueConfigException|QueueNotFoundException|QueueProviderException */ - public function getProducer(string|BackedEnum $queueName): QueueProducerInterface; + public function getProducer(string|BackedEnum $queueName): AsyncQueueProducer|SyncQueueProducer; /** Whether this queue name has a configured producer role. */ public function hasProducer(string|BackedEnum $queueName): bool; diff --git a/src/Provider/QueueProducerStatusProvider.php b/src/Provider/QueueProducerStatusProvider.php new file mode 100644 index 00000000..dab19455 --- /dev/null +++ b/src/Provider/QueueProducerStatusProvider.php @@ -0,0 +1,30 @@ +producerProvider->getProducer($queueName)->getStatus(); + } + + public function hasStatus(string|BackedEnum $queueName): bool + { + return $this->producerProvider->hasProducer($queueName); + } + + /** @return list */ + public function getStatusQueueNames(): array + { + return $this->producerProvider->getProducerQueueNames(); + } +} diff --git a/src/Provider/QueueProducerStatusProviderInterface.php b/src/Provider/QueueProducerStatusProviderInterface.php new file mode 100644 index 00000000..38dfd4ea --- /dev/null +++ b/src/Provider/QueueProducerStatusProviderInterface.php @@ -0,0 +1,21 @@ + Queue names which have a configured producer status capability. */ + public function getStatusQueueNames(): array; +} diff --git a/src/QueueConsumer.php b/src/QueueConsumer.php index ffcfbe7b..4f9c25a8 100644 --- a/src/QueueConsumer.php +++ b/src/QueueConsumer.php @@ -9,15 +9,15 @@ use Yiisoft\Queue\Adapter\AdapterInterface; use Yiisoft\Queue\Cli\LoopInterface; use Yiisoft\Queue\Message\MessageInterface; -use Yiisoft\Queue\Worker\WorkerInterface; +use Yiisoft\Queue\Worker\Worker; /** Consumes messages for one logical queue. */ -final class QueueConsumer implements QueueConsumerInterface +final class QueueConsumer { private string $queueName; public function __construct( - private readonly WorkerInterface $worker, + private readonly Worker $worker, private readonly LoopInterface $loop, private readonly LoggerInterface $logger, private readonly ?AdapterInterface $adapter = null, diff --git a/src/QueueConsumerInterface.php b/src/QueueConsumerInterface.php deleted file mode 100644 index a0a72163..00000000 --- a/src/QueueConsumerInterface.php +++ /dev/null @@ -1,25 +0,0 @@ -status = $status ?? new NotFoundQueueProducerStatus(); $this->queueName = StringNormalizer::normalize($queueName); $this->dispatcher = new PushMiddlewareDispatcher( middlewareFactory: $middlewareConfig->middlewareFactory, @@ -46,13 +50,13 @@ public function getQueueName(): string public function push(MessageInterface $message): MessageInterface { $this->logger->debug('Preparing to push message with message type "{messageType}".', ['messageType' => $message->getType()]); - $message = $this->dispatcher->dispatch($message); + $message = $this->dispatcher->dispatch(new PushRequest($message, $this->queueName))->getMessage(); $this->logger->info('Processed message with message type "{messageType}" synchronously.', ['messageType' => $message->getType()]); return $message; } - public function status(string|int $id): MessageStatus + public function getStatus(): QueueProducerStatusInterface { - return MessageStatus::NOT_FOUND; + return $this->status; } } diff --git a/src/Worker/Worker.php b/src/Worker/Worker.php index b75eedfb..1a3be8ed 100644 --- a/src/Worker/Worker.php +++ b/src/Worker/Worker.php @@ -4,10 +4,13 @@ namespace Yiisoft\Queue\Worker; +use Closure; +use LogicException; use Psr\Log\LoggerInterface; use Throwable; use Yiisoft\Queue\Exception\MessageFailureException; use Yiisoft\Queue\Message\Handler\HandlerResolver; +use Yiisoft\Queue\Message\IdEnvelope; use Yiisoft\Queue\Message\MessageInterface; use Yiisoft\Queue\Middleware\Consume\ConsumeFinalHandler; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; @@ -15,51 +18,74 @@ use Yiisoft\Queue\Middleware\FailureHandling\FailureFinalHandler; use Yiisoft\Queue\Middleware\FailureHandling\FailureHandlingRequest; use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareDispatcher; -use Yiisoft\Queue\QueueProducerInterface; -use Yiisoft\Queue\Message\IdEnvelope; +use Yiisoft\Queue\Middleware\Worker\WorkerFinalHandler; +use Yiisoft\Queue\Middleware\Worker\WorkerMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\Worker\WorkerMiddlewareFactoryInterface; +use Yiisoft\Queue\Middleware\Worker\WorkerMiddlewareInterface; +use Yiisoft\Queue\Middleware\Worker\WorkerRequest; -final class Worker implements WorkerInterface +final class Worker { + private readonly WorkerMiddlewareDispatcher $workerMiddlewareDispatcher; + private readonly WorkerFinalHandler $workerFinalHandler; + private readonly bool $hasWorkerMiddlewares; + public function __construct( private readonly LoggerInterface $logger, private readonly ConsumeMiddlewareDispatcher $consumeMiddlewareDispatcher, private readonly FailureMiddlewareDispatcher $failureMiddlewareDispatcher, private readonly HandlerResolver $handlerResolver, - ) {} - - /** - * @throws Throwable - */ - public function process( - MessageInterface $message, - string $queueName, - ?QueueProducerInterface $retryProducer = null, - ): MessageInterface { - $messageId = IdEnvelope::fromMessage($message)->getId(); - if ($messageId === null) { - $this->logger->info('Processing message without ID.'); - } else { - $this->logger->info('Processing message #{message}.', ['message' => $messageId]); - } + ?WorkerMiddlewareDispatcher $workerMiddlewareDispatcher = null, + ) { + $this->workerMiddlewareDispatcher = $workerMiddlewareDispatcher ?? new WorkerMiddlewareDispatcher( + new class implements WorkerMiddlewareFactoryInterface { + public function createWorkerMiddleware(mixed $definition): WorkerMiddlewareInterface + { + throw new LogicException('The empty worker middleware dispatcher cannot create middleware.'); + } + }, + ); + $this->workerFinalHandler = new WorkerFinalHandler( + $this->logger, + $this->consumeMiddlewareDispatcher, + $this->failureMiddlewareDispatcher, + $this->handlerResolver, + ); + $this->hasWorkerMiddlewares = $this->workerMiddlewareDispatcher->hasMiddlewares(); + } - $request = new ConsumeRequest($message, $queueName); - try { - $handler = $this->handlerResolver->resolve($message->getType()); - $finishHandler = new ConsumeFinalHandler($handler->handle(...)); - return $this->consumeMiddlewareDispatcher->dispatch($request, $finishHandler)->getMessage(); - } catch (Throwable $exception) { - $request = new FailureHandlingRequest($request->getMessage(), $exception, $request->getQueueName(), $retryProducer); + /** @param Closure(MessageInterface): MessageInterface $retry */ + public function process(MessageInterface $message, string $queueName, ?Closure $retry = null): MessageInterface + { + if (!$this->hasWorkerMiddlewares) { + $id = IdEnvelope::fromMessage($message)->getId(); + $id === null + ? $this->logger->info('Processing message without ID.') + : $this->logger->info('Processing message #{message}.', ['message' => $id]); try { - $result = $this->failureMiddlewareDispatcher->dispatch($request, new FailureFinalHandler()); - $this->logger->info($exception->getMessage()); - + $handler = $this->handlerResolver->resolve($message->getType()); + $result = $this->consumeMiddlewareDispatcher->dispatch( + new ConsumeRequest($message, $queueName), + new ConsumeFinalHandler($handler->handle(...)), + ); return $result->getMessage(); } catch (Throwable $exception) { - $exception = new MessageFailureException($message, $exception); - $this->logger->error($exception->getMessage()); - throw $exception; + $failureRequest = new FailureHandlingRequest($message, $exception, $queueName, $retry); + try { + $result = $this->failureMiddlewareDispatcher->dispatch($failureRequest, new FailureFinalHandler()); + $this->logger->info($exception->getMessage()); + return $result->getMessage(); + } catch (Throwable $failureException) { + $failureException = new MessageFailureException($message, $failureException); + $this->logger->error($failureException->getMessage()); + throw $failureException; + } } } + + return $this->workerMiddlewareDispatcher + ->dispatch(new WorkerRequest($message, $queueName, $retry), $this->workerFinalHandler) + ->getMessage(); } } diff --git a/src/Worker/WorkerInterface.php b/src/Worker/WorkerInterface.php deleted file mode 100644 index 08ca5849..00000000 --- a/src/Worker/WorkerInterface.php +++ /dev/null @@ -1,18 +0,0 @@ -queueName; - } -} diff --git a/stubs/StubWorker.php b/stubs/StubWorker.php deleted file mode 100644 index 9e8bec68..00000000 --- a/stubs/StubWorker.php +++ /dev/null @@ -1,23 +0,0 @@ -producer->push($params['message']); + /** @var MessageInterface $message */ + $message = $params['message']; + $this->producer->push($message); } public function provideConsume(): Generator diff --git a/tests/Benchmark/Support/VoidAdapter.php b/tests/Benchmark/Support/VoidAdapter.php index 363a2489..c2882329 100644 --- a/tests/Benchmark/Support/VoidAdapter.php +++ b/tests/Benchmark/Support/VoidAdapter.php @@ -17,7 +17,7 @@ final class VoidAdapter implements AdapterInterface /** * @var string A serialized message */ - public string $message; + public string $message = ''; public function __construct(private readonly MessageSerializer $serializer) {} diff --git a/tests/Integration/Config/DebugIntegrationTest.php b/tests/Integration/Config/DebugIntegrationTest.php new file mode 100644 index 00000000..5e8c991f --- /dev/null +++ b/tests/Integration/Config/DebugIntegrationTest.php @@ -0,0 +1,186 @@ +middleware('push-user'); + $userWorker = $this->middleware('worker-user'); + $params = ArrayHelper::merge($params, [ + 'yiisoft/yii-debug' => ['enabled' => $enabled], + 'yiisoft/queue' => [ + 'middlewares-push' => [$userPush], + 'middlewares-worker' => [$userWorker], + ], + ]); + $di = (static function (array $params): array { + return require __DIR__ . '/../../../config/di.php'; + })($params); + + $adapter = new class implements AdapterInterface { + public int $statusCalls = 0; + + public function runExisting(callable $handlerCallback): void {} + + public function status(string|int $id): MessageStatus + { + $this->statusCalls++; + return MessageStatus::NOT_FOUND; + } + + public function push(MessageInterface $message): MessageInterface + { + return $message; + } + + public function subscribe(callable $handlerCallback): void {} + }; + $definitions = ArrayHelper::merge($di, [ + 'config.adapter' => $adapter, + QueueProducerProviderInterface::class => static fn(ContainerInterface $container): QueueProducerProviderInterface => new QueueFactoryProvider([ + ConfigQueue::MAIN->value => ['producer' => [ + 'class' => AsyncQueueProducer::class, + '__construct()' => ['adapter' => $adapter, 'queueName' => ConfigQueue::MAIN], + ]], + ], $container), + LoggerInterface::class => new NullLogger(), + QueueCollector::class => QueueCollector::class, + QueueProducerStatusProvider::class => QueueProducerStatusProvider::class, + QueueProducerStatusProviderInterface::class => $enabled + ? static function (ContainerInterface $container): QueueProducerStatusProviderInterface { + $provider = $container->get(QueueProducerStatusProvider::class); + $collector = $container->get(QueueCollector::class); + assert($provider instanceof QueueProducerStatusProvider); + assert($collector instanceof QueueCollector); + return new QueueProducerStatusProviderProxy($provider, $collector); + } + : QueueProducerStatusProvider::class, + ]); + + $container = new Container(ContainerConfig::create()->withDefinitions($definitions)); + $status = $container->get(QueueProducerStatusProviderInterface::class); + self::assertInstanceOf($enabled ? QueueProducerStatusProviderProxy::class : QueueProducerStatusProvider::class, $status); + /** @var array{yiisoft/yii-debug: array{trackedServices: array}} $params */ + self::assertSame( + [QueueProducerStatusProviderInterface::class => [QueueProducerStatusProviderProxy::class, QueueCollector::class]], + $params['yiisoft/yii-debug']['trackedServices'], + ); + + $collector = $container->get(QueueCollector::class); + $collector->startup(); + self::assertSame(0, $adapter->statusCalls); + self::assertSame(MessageStatus::NOT_FOUND, $status->getStatus(ConfigQueue::MAIN)->status('missing')); + /** @psalm-suppress DocblockTypeContradiction */ + self::assertSame(1, $adapter->statusCalls); + self::assertSame(['queue'], $status->getStatusQueueNames()); + + $push = $container->get(PushMiddlewareConfig::class); + self::assertSame($enabled ? PushDebugMiddleware::class : $this->middlewareClass($push->commonMiddlewareDefinitions[0]), $this->middlewareClass($push->commonMiddlewareDefinitions[0])); + self::assertCount($enabled ? 2 : 1, $push->commonMiddlewareDefinitions); + self::assertSame($userPush, $push->commonMiddlewareDefinitions[$enabled ? 1 : 0]); + $this->dispatchPush($push); + + $worker = $container->get(WorkerMiddlewareDispatcher::class); + self::assertTrue($worker->hasMiddlewares()); + $this->dispatchWorker($worker); + /** @var array{pushes: array>, processingMessages: array>} $collected */ + $collected = $collector->getCollected(); + self::assertSame($enabled ? ['queue'] : [], array_keys($collected['pushes'])); + self::assertSame($enabled ? ['queue'] : [], array_keys($collected['processingMessages'])); + } + + public static function debugStates(): iterable + { + yield 'disabled' => [false]; + yield 'enabled' => [true]; + } + + private function middleware(string $stage): object + { + return new class ($stage) implements PushMiddlewareInterface, WorkerMiddlewareInterface { + public function __construct(private string $stage) {} + + public function processPush(PushRequest $request, PushHandlerInterface $handler): PushRequest + { + return $handler->handlePush($request); + } + + public function processWorker(WorkerRequest $request, WorkerHandlerInterface $handler): WorkerRequest + { + return $handler->handleWorker($request); + } + }; + } + + private function middlewareClass(mixed $middleware): string + { + return is_object($middleware) ? $middleware::class : (string) $middleware; + } + + private function dispatchPush(PushMiddlewareConfig $config): void + { + $handler = new class implements PushHandlerInterface { + public function handlePush(PushRequest $request): PushRequest + { + return $request; + } + }; + (new PushMiddlewareDispatcher($config->middlewareFactory, $config->commonMiddlewareDefinitions, $handler)) + ->dispatch(new PushRequest(new GenericMessage('config', null), ConfigQueue::MAIN)); + } + + private function dispatchWorker(WorkerMiddlewareDispatcher $dispatcher): void + { + $handler = new class implements WorkerHandlerInterface { + public function handleWorker(WorkerRequest $request): WorkerRequest + { + return $request; + } + }; + $dispatcher->dispatch(new WorkerRequest(new GenericMessage('worker', null), ConfigQueue::MAIN), $handler); + } +} + +enum ConfigQueue: string +{ + case MAIN = 'queue'; +} diff --git a/tests/Integration/MiddlewareTest.php b/tests/Integration/MiddlewareTest.php index 55e906bd..7eb22306 100644 --- a/tests/Integration/MiddlewareTest.php +++ b/tests/Integration/MiddlewareTest.php @@ -23,11 +23,11 @@ use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; use Yiisoft\Queue\SyncQueueProducer; -use Yiisoft\Queue\QueueProducerInterface; use Yiisoft\Queue\Message\Handler\HandlerResolver; use Yiisoft\Queue\Tests\Integration\Support\TestMiddleware; use Yiisoft\Queue\Worker\Worker; -use Yiisoft\Queue\Worker\WorkerInterface; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactoryInterface; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareFactoryInterface; final class MiddlewareTest extends TestCase { @@ -52,8 +52,12 @@ public function testFullStackPush(): void new TestMiddleware('common 2'), ], ); - $worker = $this->createMock(WorkerInterface::class); - $worker->method('process')->willReturnArgument(0); + $worker = new Worker( + $this->createMock(LoggerInterface::class), + new ConsumeMiddlewareDispatcher($this->createMock(ConsumeMiddlewareFactoryInterface::class)), + new FailureMiddlewareDispatcher($this->createMock(FailureMiddlewareFactoryInterface::class), []), + new HandlerResolver(['test' => static function (): void {}], new SimpleContainer()), + ); $queue = new SyncQueueProducer( $this->createMock(LoggerInterface::class), $pushMiddlewareConfig, @@ -115,24 +119,22 @@ public function testFullStackFailure(): void $message = new GenericMessage('simple', null); $queueCallback = static fn(MessageInterface $message): MessageInterface => $message; - $queue = $this->createMock(QueueProducerInterface::class); - $container = new SimpleContainer([SendAgainMiddleware::class => new SendAgainMiddleware('test-container', 1, $queue)]); - - $queue->expects(self::exactly(7))->method('push')->willReturnCallback($queueCallback); - $queue->method('getQueueName')->willReturn('simple'); + $container = new SimpleContainer([ + SendAgainMiddleware::class => new SendAgainMiddleware('test-container', 1, $queueCallback), + ]); $middlewares = [ 'test-queue' => [ - new SendAgainMiddleware('test', 1, $queue), + new SendAgainMiddleware('test', 1, $queueCallback), [ 'class' => SendAgainMiddleware::class, - '__construct()' => ['test-factory', 1, $queue], + '__construct()' => ['test-factory', 1, $queueCallback], ], [ - new SendAgainMiddleware('test-callable', 1, $queue), + new SendAgainMiddleware('test-callable', 1, $queueCallback), 'processFailure', ], - fn(): SendAgainMiddleware => new SendAgainMiddleware('test-callable-2', 1, $queue), + fn(): SendAgainMiddleware => new SendAgainMiddleware('test-callable-2', 1, $queueCallback), SendAgainMiddleware::class, new ExponentialDelayMiddleware( 'test', @@ -140,7 +142,7 @@ public function testFullStackFailure(): void 1, 5, 2, - $queue, + $queueCallback, ), ], ]; @@ -150,7 +152,7 @@ public function testFullStackFailure(): void ); $iteration = 0; - $request = new FailureHandlingRequest($message, $exception, 'test-queue', $queue); + $request = new FailureHandlingRequest($message, $exception, 'test-queue', $queueCallback); $finalHandler = new FailureFinalHandler(); try { do { diff --git a/tests/Integration/QueueProviderTest.php b/tests/Integration/QueueProviderTest.php index cd9e5e31..a776ab0e 100644 --- a/tests/Integration/QueueProviderTest.php +++ b/tests/Integration/QueueProviderTest.php @@ -5,48 +5,50 @@ namespace Yiisoft\Queue\Tests\Integration; use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Input\StringInput; -use Symfony\Component\Console\Output\NullOutput; +use Psr\Log\NullLogger; use Yiisoft\Definitions\Reference; -use Yiisoft\Queue\Command\ListenCommand; -use Yiisoft\Queue\Debug\QueueCollector; -use Yiisoft\Queue\Debug\QueueConsumerDecorator; -use Yiisoft\Queue\Debug\QueueConsumerProviderProxy; -use Yiisoft\Queue\Debug\QueueProducerDecorator; -use Yiisoft\Queue\Debug\QueueProducerProviderProxy; -use Yiisoft\Queue\Message\GenericMessage; +use Yiisoft\Queue\AsyncQueueProducer; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactory; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareFactory; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; +use Yiisoft\Queue\Message\Handler\HandlerResolver; use Yiisoft\Queue\Provider\PredefinedQueueProvider; use Yiisoft\Queue\Provider\QueueFactoryProvider; use Yiisoft\Queue\Provider\QueueNotFoundException; -use Yiisoft\Queue\QueueConsumerInterface; -use Yiisoft\Queue\QueueProducerInterface; -use Yiisoft\Queue\Stubs\StubQueueConsumer; -use Yiisoft\Queue\Stubs\StubQueueProducer; +use Yiisoft\Queue\QueueConsumer; +use Yiisoft\Queue\Stubs\InMemoryAdapter; +use Yiisoft\Queue\Worker\Worker; +use Yiisoft\Queue\Cli\SimpleLoop; use Yiisoft\Test\Support\Container\SimpleContainer; final class QueueProviderTest extends TestCase { - public function testFactoryRoleMapsResolveThroughContainerAndKeepCapabilitiesSeparate(): void + public function testFactoryProviderResolvesConcreteRolesThroughContainer(): void { - $container = new SimpleContainer(['producer-name' => 'factory-both']); + $producer = $this->producer('factory-name'); + $consumer = $this->consumer(); + $container = new SimpleContainer([ + 'factory-producer' => $producer, + 'factory-consumer' => $consumer, + ]); $provider = new QueueFactoryProvider([ - 'both' => [ - 'producer' => [ - 'class' => StubQueueProducer::class, - '__construct()' => ['queueName' => Reference::to('producer-name')], - ], - 'consumer' => StubQueueConsumer::class, + 'factory-name' => [ + 'producer' => Reference::to('factory-producer'), + 'consumer' => Reference::to('factory-consumer'), ], - 'producer-only' => ['producer' => StubQueueProducer::class], - 'consumer-only' => ['consumer' => StubQueueConsumer::class], + 'producer-only' => ['producer' => Reference::to('factory-producer')], + 'consumer-only' => ['consumer' => Reference::to('factory-consumer')], ], $container); - self::assertSame(['both', 'producer-only'], $provider->getProducerQueueNames()); - self::assertSame(['both', 'consumer-only'], $provider->getConsumerQueueNames()); - self::assertSame('factory-both', $provider->getProducer('both')->getQueueName()); - self::assertInstanceOf(StubQueueConsumer::class, $provider->getConsumer('both')); - self::assertInstanceOf(StubQueueProducer::class, $provider->getProducer('producer-only')); - self::assertInstanceOf(StubQueueConsumer::class, $provider->getConsumer('consumer-only')); + self::assertSame(['factory-name', 'producer-only'], $provider->getProducerQueueNames()); + self::assertSame(['factory-name', 'consumer-only'], $provider->getConsumerQueueNames()); + self::assertSame('factory-name', $provider->getProducer('factory-name')->getQueueName()); + self::assertInstanceOf(AsyncQueueProducer::class, $provider->getProducer('producer-only')); + self::assertSame('factory-name', $provider->getProducer('producer-only')->getQueueName()); + self::assertInstanceOf(QueueConsumer::class, $provider->getConsumer('consumer-only')); self::assertFalse($provider->hasConsumer('producer-only')); self::assertFalse($provider->hasProducer('consumer-only')); @@ -54,52 +56,43 @@ public function testFactoryRoleMapsResolveThroughContainerAndKeepCapabilitiesSep $provider->getConsumer('producer-only'); } - public function testPredefinedRoleMapsAndListenCommandUseConsumerOnlyService(): void + public function testPredefinedProviderExposesConcreteRolesAndNormalizedNames(): void { - $consumer = $this->createMock(QueueConsumerInterface::class); - $consumer->expects(self::once())->method('listen'); $provider = new PredefinedQueueProvider([ - 'both' => ['producer' => new StubQueueProducer('predefined-both'), 'consumer' => new StubQueueConsumer()], - 'producer-only' => ['producer' => new StubQueueProducer()], - 'consumer-only' => ['consumer' => $consumer], + 'mixed-name' => ['producer' => $this->producer('mixed-name'), 'consumer' => $this->consumer()], + 'producer-only' => ['producer' => $this->producer('producer-only')], ]); - self::assertSame(['both', 'producer-only'], $provider->getProducerQueueNames()); - self::assertSame(['both', 'consumer-only'], $provider->getConsumerQueueNames()); - self::assertInstanceOf(QueueProducerInterface::class, $provider->getProducer('both')); - self::assertInstanceOf(QueueConsumerInterface::class, $provider->getConsumer('both')); + self::assertSame(['mixed-name', 'producer-only'], $provider->getProducerQueueNames()); + self::assertSame(['mixed-name'], $provider->getConsumerQueueNames()); + self::assertInstanceOf(AsyncQueueProducer::class, $provider->getProducer('mixed-name')); + self::assertInstanceOf(QueueConsumer::class, $provider->getConsumer('mixed-name')); self::assertFalse($provider->hasConsumer('producer-only')); - self::assertFalse($provider->hasProducer('consumer-only')); - - self::assertSame(0, (new ListenCommand($provider))->run(new StringInput('consumer-only'), new NullOutput())); - try { - $provider->getProducer('consumer-only'); - self::fail('A consumer-only queue must not expose a producer service.'); - } catch (QueueNotFoundException) { - self::addToAssertionCount(1); - } + $this->expectException(QueueNotFoundException::class); + $provider->getConsumer('producer-only'); } - public function testDebugProxiesPreserveSeparatedProviderRoles(): void + private function producer(string $name): AsyncQueueProducer { - $provider = new PredefinedQueueProvider([ - 'mixed-name' => ['producer' => new StubQueueProducer('mixed-name')], - 'consumer-only' => ['consumer' => new StubQueueConsumer()], - ]); - $collector = new QueueCollector(); - $collector->startup(); - - $producerProvider = new QueueProducerProviderProxy($provider, $collector); - $consumerProvider = new QueueConsumerProviderProxy($provider, $collector); - $producer = $producerProvider->getProducer('mixed-name'); - $consumer = $consumerProvider->getConsumer('consumer-only'); - $producer->push(new GenericMessage('test', 'payload')); + $container = new SimpleContainer(); + return new AsyncQueueProducer( + new NullLogger(), + new PushMiddlewareConfig(new PushMiddlewareFactory($container)), + new InMemoryAdapter(), + $name, + ); + } - self::assertInstanceOf(QueueProducerDecorator::class, $producer); - self::assertInstanceOf(QueueConsumerDecorator::class, $consumer); - self::assertSame(['mixed-name'], $producerProvider->getProducerQueueNames()); - self::assertSame(['consumer-only'], $consumerProvider->getConsumerQueueNames()); - self::assertSame(1, $collector->getSummary()['countPushes']); + private function consumer(): QueueConsumer + { + $container = new SimpleContainer(); + $worker = new Worker( + new NullLogger(), + new ConsumeMiddlewareDispatcher(new ConsumeMiddlewareFactory($container)), + new FailureMiddlewareDispatcher(new FailureMiddlewareFactory($container), []), + new HandlerResolver([], $container), + ); + return new QueueConsumer($worker, new SimpleLoop(), new NullLogger()); } } diff --git a/tests/Integration/Support/TestMiddleware.php b/tests/Integration/Support/TestMiddleware.php index 05eb63a7..bc9f7b23 100644 --- a/tests/Integration/Support/TestMiddleware.php +++ b/tests/Integration/Support/TestMiddleware.php @@ -5,28 +5,31 @@ namespace Yiisoft\Queue\Tests\Integration\Support; use Yiisoft\Queue\Message\GenericMessage; -use Yiisoft\Queue\Message\MessageInterface; use Yiisoft\Queue\Middleware\Consume\ConsumeRequest; use Yiisoft\Queue\Middleware\Consume\ConsumeHandlerInterface; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareInterface; use Yiisoft\Queue\Middleware\Push\PushHandlerInterface; use Yiisoft\Queue\Middleware\Push\PushMiddlewareInterface; +use Yiisoft\Queue\Middleware\Push\PushRequest; final class TestMiddleware implements PushMiddlewareInterface, ConsumeMiddlewareInterface { public function __construct(private readonly string $stage) {} - public function processPush(MessageInterface $message, PushHandlerInterface $handler): MessageInterface + public function processPush(PushRequest $request, PushHandlerInterface $handler): PushRequest { + $message = $request->getMessage(); + /** @var array|scalar|null> $stack */ $stack = $message->getPayload(); $stack[] = $this->stage; - return $handler->handlePush(new GenericMessage($message->getType(), $stack)); + return $handler->handlePush($request->withMessage(new GenericMessage($message->getType(), $stack))); } public function processConsume(ConsumeRequest $request, ConsumeHandlerInterface $handler): ConsumeRequest { $message = $request->getMessage(); + /** @var array|scalar|null> $stack */ $stack = $message->getPayload(); $stack[] = $this->stage; $messageNew = new GenericMessage($message->getType(), $stack); diff --git a/tests/TestCase.php b/tests/TestCase.php index 8660c167..ae52c5c1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -21,11 +21,9 @@ use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; use Yiisoft\Queue\AsyncQueueProducer; -use Yiisoft\Queue\QueueProducerInterface; use Yiisoft\Queue\Message\Handler\HandlerResolver; use Yiisoft\Queue\SyncQueueProducer; use Yiisoft\Queue\Worker\Worker; -use Yiisoft\Queue\Worker\WorkerInterface; /** * Base Test Case. @@ -33,9 +31,9 @@ abstract class TestCase extends BaseTestCase { protected ?ContainerInterface $container = null; - protected ?QueueProducerInterface $queue = null; + protected AsyncQueueProducer|SyncQueueProducer|null $queue = null; protected ?LoopInterface $loop = null; - protected ?WorkerInterface $worker = null; + protected ?Worker $worker = null; protected array $eventHandlers = []; protected int $executionTimes; @@ -52,9 +50,9 @@ protected function setUp(): void } /** - * @return QueueProducerInterface The same object every time + * @return AsyncQueueProducer|SyncQueueProducer The same object every time */ - protected function getQueue(): QueueProducerInterface + protected function getQueue(): AsyncQueueProducer|SyncQueueProducer { $this->queue ??= $this->createQueue(); @@ -68,7 +66,7 @@ protected function getLoop(): LoopInterface return $this->loop; } - protected function getWorker(): WorkerInterface + protected function getWorker(): Worker { $this->worker ??= $this->createWorker(); @@ -85,7 +83,7 @@ protected function getContainer(): ContainerInterface protected function createQueue( ?AdapterInterface $adapter = null, string|BackedEnum $queueName = DefaultQueue::NAME, - ): QueueProducerInterface { + ): AsyncQueueProducer|SyncQueueProducer { return $adapter === null ? new SyncQueueProducer( new NullLogger(), @@ -106,7 +104,7 @@ protected function createLoop(): LoopInterface return new SimpleLoop(); } - protected function createWorker(): WorkerInterface + protected function createWorker(): Worker { return new Worker( new NullLogger(), @@ -139,6 +137,7 @@ protected function getEventHandlers(): array return $this->eventHandlers; } + /** @return array */ protected function getMessageHandlers(): array { return [ diff --git a/tests/Unit/Command/ListenAllCommandTest.php b/tests/Unit/Command/ListenAllCommandTest.php index dd457fff..d6b606dd 100644 --- a/tests/Unit/Command/ListenAllCommandTest.php +++ b/tests/Unit/Command/ListenAllCommandTest.php @@ -5,15 +5,28 @@ namespace Yiisoft\Queue\Tests\Unit\Command; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\OutputInterface; +use Yiisoft\Queue\AsyncQueueProducer; use Yiisoft\Queue\Cli\LoopInterface; +use Yiisoft\Queue\Cli\SimpleLoop; use Yiisoft\Queue\Command\ListenAllCommand; +use Yiisoft\Queue\Message\GenericMessage; +use Yiisoft\Queue\Message\Handler\HandlerResolver; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactory; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareFactory; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; use Yiisoft\Queue\Provider\PredefinedQueueProvider; -use Yiisoft\Queue\QueueConsumerInterface; -use Yiisoft\Queue\Stubs\StubQueueProducer; +use Yiisoft\Queue\QueueConsumer; +use Yiisoft\Queue\Stubs\InMemoryAdapter; +use Yiisoft\Queue\Worker\Worker; +use Yiisoft\Test\Support\Container\SimpleContainer; final class ListenAllCommandTest extends TestCase { @@ -28,16 +41,35 @@ public function testReportsWhenNoConsumersAreConfigured(): void public function testRunsOnlyConsumerRolesByDefault(): void { - $consumer = $this->createMock(QueueConsumerInterface::class); - $consumer->expects($this->once())->method('run')->willReturn(0); + $processed = 0; + $adapter = new InMemoryAdapter(); + $adapter->push(new GenericMessage('test', null)); + $consumer = $this->consumer($adapter, static function () use (&$processed): void { + $processed++; + }); $loop = $this->createMock(LoopInterface::class); $loop->method('canContinue')->willReturn(true, false); $command = new ListenAllCommand(new PredefinedQueueProvider([ - 'producer' => ['producer' => new StubQueueProducer()], + 'producer' => ['producer' => new AsyncQueueProducer(new NullLogger(), new PushMiddlewareConfig(new PushMiddlewareFactory(new SimpleContainer())), new InMemoryAdapter())], 'consumer' => ['consumer' => $consumer], ]), $loop); $input = new ArrayInput([], $command->getNativeDefinition()); $input->setOption('pause', 0); + self::assertSame(0, $command->run($input, $this->createMock(OutputInterface::class))); + self::assertSame(1, $processed); + } + + private function consumer(InMemoryAdapter $adapter, callable $handler): QueueConsumer + { + $container = new SimpleContainer(); + $worker = new Worker( + new NullLogger(), + new ConsumeMiddlewareDispatcher(new ConsumeMiddlewareFactory($container)), + new FailureMiddlewareDispatcher(new FailureMiddlewareFactory($container), []), + new HandlerResolver(['test' => $handler], $container), + ); + + return new QueueConsumer($worker, new SimpleLoop(), new NullLogger(), $adapter); } } diff --git a/tests/Unit/Command/ListenCommandTest.php b/tests/Unit/Command/ListenCommandTest.php index fedceca5..7cdca4b6 100644 --- a/tests/Unit/Command/ListenCommandTest.php +++ b/tests/Unit/Command/ListenCommandTest.php @@ -8,16 +8,46 @@ use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\OutputInterface; use Yiisoft\Queue\Command\ListenCommand; +use Yiisoft\Queue\Message\GenericMessage; use Yiisoft\Queue\Provider\PredefinedQueueProvider; -use Yiisoft\Queue\QueueConsumerInterface; +use Yiisoft\Queue\QueueConsumer; +use Yiisoft\Queue\Stubs\InMemoryAdapter; +use Yiisoft\Queue\Worker\Worker; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactory; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareFactory; +use Yiisoft\Queue\Message\Handler\HandlerResolver; +use Yiisoft\Queue\Cli\SimpleLoop; +use Yiisoft\Test\Support\Container\SimpleContainer; +use Psr\Log\NullLogger; final class ListenCommandTest extends TestCase { public function testListensSelectedConsumer(): void { - $consumer = $this->createMock(QueueConsumerInterface::class); - $consumer->expects($this->once())->method('listen'); + $processed = 0; + $adapter = new InMemoryAdapter(); + $adapter->push(new GenericMessage('test', null)); + $consumer = $this->consumer($adapter, static function () use (&$processed): void { + $processed++; + }); $command = new ListenCommand(new PredefinedQueueProvider(['queue' => ['consumer' => $consumer]])); + self::assertSame(0, $command->run(new StringInput('queue'), $this->createMock(OutputInterface::class))); + self::assertSame(1, $processed); + } + + private function consumer(InMemoryAdapter $adapter, callable $handler): QueueConsumer + { + $container = new SimpleContainer(); + $worker = new Worker( + new NullLogger(), + new ConsumeMiddlewareDispatcher(new ConsumeMiddlewareFactory($container)), + new FailureMiddlewareDispatcher(new FailureMiddlewareFactory($container), []), + new HandlerResolver(['test' => $handler], $container), + ); + + return new QueueConsumer($worker, new SimpleLoop(), new NullLogger(), $adapter); } } diff --git a/tests/Unit/Command/RunCommandTest.php b/tests/Unit/Command/RunCommandTest.php index 133cdd83..a96132bb 100644 --- a/tests/Unit/Command/RunCommandTest.php +++ b/tests/Unit/Command/RunCommandTest.php @@ -5,35 +5,73 @@ namespace Yiisoft\Queue\Tests\Unit\Command; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\OutputInterface; +use Yiisoft\Queue\AsyncQueueProducer; use Yiisoft\Queue\Command\RunCommand; use Yiisoft\Queue\DefaultQueue; +use Yiisoft\Queue\Message\GenericMessage; +use Yiisoft\Queue\Message\Handler\HandlerResolver; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactory; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareFactory; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; use Yiisoft\Queue\Provider\PredefinedQueueProvider; -use Yiisoft\Queue\QueueConsumerInterface; -use Yiisoft\Queue\Stubs\StubQueueProducer; +use Yiisoft\Queue\QueueConsumer; +use Yiisoft\Queue\Stubs\InMemoryAdapter; +use Yiisoft\Queue\Worker\Worker; +use Yiisoft\Test\Support\Container\SimpleContainer; +use Yiisoft\Queue\Cli\SimpleLoop; final class RunCommandTest extends TestCase { public function testRunsSelectedConsumer(): void { - $consumer = $this->createMock(QueueConsumerInterface::class); - $consumer->expects($this->once())->method('run')->with(5)->willReturn(3); + $processed = 0; + $adapter = new InMemoryAdapter(); + $adapter->push(new GenericMessage('test', null)); + $adapter->push(new GenericMessage('test', null)); + $consumer = $this->consumer($adapter, static function () use (&$processed): void { + $processed++; + }); $command = new RunCommand(new PredefinedQueueProvider(['queue' => ['consumer' => $consumer]])); $output = $this->createMock(OutputInterface::class); $output->expects($this->once())->method('write')->with('Processing queue queue... '); - $output->expects($this->once())->method('writeln')->with('Messages processed: 3.'); - self::assertSame(0, $command->run(new StringInput('queue --limit=5'), $output)); + $output->expects($this->once())->method('writeln')->with('Messages processed: 1.'); + + self::assertSame(0, $command->run(new StringInput('queue --limit=1'), $output)); + self::assertSame(1, $processed); } public function testDefaultRunSkipsProducerOnlyQueues(): void { - $consumer = $this->createMock(QueueConsumerInterface::class); - $consumer->expects($this->once())->method('run')->willReturn(0); + $adapter = new InMemoryAdapter(); + $processed = 0; + $consumer = $this->consumer($adapter, static function () use (&$processed): void { + $processed++; + }); $command = new RunCommand(new PredefinedQueueProvider([ - 'producer' => ['producer' => new StubQueueProducer()], + 'producer' => ['producer' => new AsyncQueueProducer(new NullLogger(), new PushMiddlewareConfig(new PushMiddlewareFactory(new SimpleContainer())), new InMemoryAdapter())], DefaultQueue::NAME => ['consumer' => $consumer], ])); + self::assertSame(0, $command->run(new StringInput(''), $this->createMock(OutputInterface::class))); + self::assertSame(0, $processed); + } + + private function consumer(InMemoryAdapter $adapter, callable $handler): QueueConsumer + { + $container = new SimpleContainer(); + $worker = new Worker( + new NullLogger(), + new ConsumeMiddlewareDispatcher(new ConsumeMiddlewareFactory($container)), + new FailureMiddlewareDispatcher(new FailureMiddlewareFactory($container), []), + new HandlerResolver(['test' => $handler], $container), + ); + + return new QueueConsumer($worker, new SimpleLoop(), new NullLogger(), $adapter); } } diff --git a/tests/Unit/Debug/PushDebugMiddlewareTest.php b/tests/Unit/Debug/PushDebugMiddlewareTest.php new file mode 100644 index 00000000..0f4613e5 --- /dev/null +++ b/tests/Unit/Debug/PushDebugMiddlewareTest.php @@ -0,0 +1,167 @@ +startup(); + $message = new GenericMessage('in', null); + $returned = new GenericMessage('out', null); + $handler = new class ($returned) implements PushHandlerInterface { + public function __construct(private PushRequest|GenericMessage $returned) {} + + public function handlePush(PushRequest $request): PushRequest + { + return $request->withMessage($this->returned instanceof PushRequest ? $this->returned->getMessage() : $this->returned); + } + }; + + $result = (new PushDebugMiddleware($collector))->processPush(new PushRequest($message, 'entry'), $handler); + + self::assertSame($returned, $result->getMessage()); + /** @var array{pushes: array>} $collected */ + $collected = $collector->getCollected(); + self::assertSame($returned, $collected['pushes']['entry'][0]['message']); + self::assertSame(1, $collector->getSummary()['countPushes']); + } + + public function testCollectsExternalPushCallerSourceFrame(): void + { + $collector = new QueueCollector(); + $collector->startup(); + $middleware = new PushDebugMiddleware($collector); + $request = new PushRequest(new GenericMessage('test', null), 'queue'); + $handler = new class implements PushHandlerInterface { + public function handlePush(PushRequest $request): PushRequest + { + return $request; + } + }; + + $this->callPushFromExternalCaller($middleware, $request, $handler); + + /** @var array{pushes: array>} $collected */ + $collected = $collector->getCollected(); + $line = $collected['pushes']['queue'][0]['line']; + self::assertMatchesRegularExpression('/PushDebugMiddlewareTest\\.php:\\d+$/', $line); + self::assertStringNotContainsString('PushMiddlewareStack.php', $line); + self::assertStringNotContainsString('AsyncQueueProducer.php', $line); + } + + public function testCollectsCallerSourceThroughAsyncProducer(): void + { + $collector = new QueueCollector(); + $collector->startup(); + $container = new SimpleContainer([QueueCollector::class => $collector]); + $producer = new AsyncQueueProducer( + new NullLogger(), + new PushMiddlewareConfig( + new PushMiddlewareFactory($container), + [new PushDebugMiddleware($collector)], + ), + new InMemoryAdapter(), + ); + + $this->pushFromExternalCaller($producer); + + /** @var array{pushes: array>} $collected */ + $collected = $collector->getCollected(); + $line = $collected['pushes']['yii-queue'][0]['line']; + self::assertMatchesRegularExpression('/PushDebugMiddlewareTest\\.php:\\d+$/', $line); + self::assertStringNotContainsString('PushMiddlewareStack.php', $line); + self::assertStringNotContainsString('AsyncQueueProducer.php', $line); + } + + public function testShortCircuitIsCollectedAndExceptionIsNot(): void + { + $collector = new QueueCollector(); + $collector->startup(); + $middleware = new PushDebugMiddleware($collector); + $message = new GenericMessage('test', null); + $shortCircuit = new class implements PushHandlerInterface { + public function handlePush(PushRequest $request): PushRequest + { + return $request; + } + }; + $middleware->processPush(new PushRequest($message, 'queue'), $shortCircuit); + self::assertSame(1, $collector->getSummary()['countPushes']); + + $failing = new class implements PushHandlerInterface { + public function handlePush(PushRequest $request): PushRequest + { + throw new RuntimeException('failed'); + } + }; + try { + $middleware->processPush(new PushRequest($message, 'queue'), $failing); + } catch (RuntimeException) { + } + self::assertSame(1, $collector->getSummary()['countPushes']); + } + + public function testInactiveCollectorDoesNotRecord(): void + { + $collector = new QueueCollector(); + $handler = new class implements PushHandlerInterface { + public function handlePush(PushRequest $request): PushRequest + { + return $request; + } + }; + (new PushDebugMiddleware($collector))->processPush(new PushRequest(new GenericMessage('test', null), 'queue'), $handler); + self::assertSame([], $collector->getCollected()); + } + + private function pushFromExternalCaller(AsyncQueueProducer $producer): void + { + $producer->push(new GenericMessage('test', null)); + } + + /** + * Keep the helper calls between the middleware and this test's call site. + */ + private function callPushFromExternalCaller( + PushDebugMiddleware $middleware, + PushRequest $request, + PushHandlerInterface $handler, + ): void { + $this->callPushFromPipeline($middleware, $request, $handler); + } + + private function callPushFromPipeline( + PushDebugMiddleware $middleware, + PushRequest $request, + PushHandlerInterface $handler, + ): void { + $this->callPushFromStack($middleware, $request, $handler); + } + + private function callPushFromStack( + PushDebugMiddleware $middleware, + PushRequest $request, + PushHandlerInterface $handler, + ): void { + $middleware->processPush($request, $handler); + } +} diff --git a/tests/Unit/Debug/QueueDecoratorTest.php b/tests/Unit/Debug/QueueDecoratorTest.php deleted file mode 100644 index 48913476..00000000 --- a/tests/Unit/Debug/QueueDecoratorTest.php +++ /dev/null @@ -1,42 +0,0 @@ -createMock(QueueProducerInterface::class); - $producer->method('getQueueName')->willReturn('queue'); - $producer->expects($this->once())->method('push')->with($message)->willReturn($message); - $producer->expects($this->once())->method('status')->with('1')->willReturn(MessageStatus::WAITING); - $collector = new QueueCollector(); - $collector->startup(); - $decorator = new QueueProducerDecorator($producer, $collector); - self::assertSame($message, $decorator->push($message)); - self::assertSame(MessageStatus::WAITING, $decorator->status('1')); - self::assertArrayHasKey('queue', $collector->getCollected()['pushes']); - } - - public function testConsumerDecoratorDelegates(): void - { - $consumer = $this->createMock(QueueConsumerInterface::class); - $consumer->expects($this->once())->method('run')->with(2)->willReturn(1); - $consumer->expects($this->once())->method('listen'); - $decorator = new QueueConsumerDecorator($consumer, new QueueCollector()); - self::assertSame(1, $decorator->run(2)); - $decorator->listen(); - } -} diff --git a/tests/Unit/Debug/QueueProducerStatusProviderProxyTest.php b/tests/Unit/Debug/QueueProducerStatusProviderProxyTest.php new file mode 100644 index 00000000..6712c010 --- /dev/null +++ b/tests/Unit/Debug/QueueProducerStatusProviderProxyTest.php @@ -0,0 +1,33 @@ +createMock(QueueProducerStatusInterface::class); + $provider = $this->createMock(QueueProducerStatusProviderInterface::class); + $provider->expects(self::once())->method('getStatus')->with('main')->willReturn($status); + $provider->expects(self::once())->method('hasStatus')->with('main')->willReturn(true); + $provider->expects(self::once())->method('getStatusQueueNames')->willReturn(['main']); + + $proxy = new QueueProducerStatusProviderProxy($provider, new QueueCollector()); + self::assertInstanceOf(QueueProducerStatusInterface::class, $proxy->getStatus(DebugQueue::MAIN)); + self::assertTrue($proxy->hasStatus(DebugQueue::MAIN)); + self::assertSame(['main'], $proxy->getStatusQueueNames()); + } +} diff --git a/tests/Unit/Debug/QueueProducerStatusProxyTest.php b/tests/Unit/Debug/QueueProducerStatusProxyTest.php new file mode 100644 index 00000000..9cca70dd --- /dev/null +++ b/tests/Unit/Debug/QueueProducerStatusProxyTest.php @@ -0,0 +1,36 @@ +createMock(QueueProducerStatusInterface::class); + $status->expects(self::once())->method('status')->with('id')->willReturn(MessageStatus::DONE); + $collector = new QueueCollector(); + $collector->startup(); + $proxy = new QueueProducerStatusProxy($status, $collector); + + self::assertSame(MessageStatus::DONE, $proxy->status('id')); + self::assertSame('id', $collector->getCollected()['statuses'][0]['id']); + self::assertSame('done', $collector->getCollected()['statuses'][0]['status']); + } + + public function testInactiveCollectorStillDelegates(): void + { + $status = $this->createMock(QueueProducerStatusInterface::class); + $status->expects(self::once())->method('status')->willReturn(MessageStatus::WAITING); + $collector = new QueueCollector(); + self::assertSame(MessageStatus::WAITING, (new QueueProducerStatusProxy($status, $collector))->status(1)); + self::assertSame([], $collector->getCollected()); + } +} diff --git a/tests/Unit/Debug/QueueProviderInterfaceProxyTest.php b/tests/Unit/Debug/QueueProviderInterfaceProxyTest.php deleted file mode 100644 index f9e5fc48..00000000 --- a/tests/Unit/Debug/QueueProviderInterfaceProxyTest.php +++ /dev/null @@ -1,41 +0,0 @@ -createMock(QueueProducerInterface::class); - $provider = $this->createMock(QueueProducerProviderInterface::class); - $provider->method('getProducer')->willReturn($producer); - $proxy = new QueueProducerProviderProxy($provider, new QueueCollector()); - self::assertInstanceOf(QueueProducerDecorator::class, $proxy->getProducer('queue')); - } - - public function testConsumerProxyDelegatesOnlyConsumerRole(): void - { - $consumer = $this->createMock(QueueConsumerInterface::class); - $provider = $this->createMock(QueueConsumerProviderInterface::class); - $provider->method('getConsumer')->willReturn($consumer); - $provider->method('hasConsumer')->with('queue')->willReturn(true); - $provider->method('getConsumerQueueNames')->willReturn(['queue']); - $proxy = new QueueConsumerProviderProxy($provider, new QueueCollector()); - self::assertInstanceOf(QueueConsumerDecorator::class, $proxy->getConsumer('queue')); - self::assertTrue($proxy->hasConsumer('queue')); - self::assertSame(['queue'], $proxy->getConsumerQueueNames()); - } -} diff --git a/tests/Unit/Debug/QueueWorkerInterfaceProxyTest.php b/tests/Unit/Debug/QueueWorkerInterfaceProxyTest.php deleted file mode 100644 index 51885015..00000000 --- a/tests/Unit/Debug/QueueWorkerInterfaceProxyTest.php +++ /dev/null @@ -1,32 +0,0 @@ -startup(); - $proxy = new QueueWorkerInterfaceProxy(new StubWorker(), $collector); - - $result = $proxy->process($message, 'chan'); - - self::assertSame($message, $result); - - $collected = $collector->getCollected(); - self::assertArrayHasKey('processingMessages', $collected); - self::assertArrayHasKey('chan', $collected['processingMessages']); - self::assertCount(1, $collected['processingMessages']['chan']); - self::assertSame($message, $collected['processingMessages']['chan'][0]); - } -} diff --git a/tests/Unit/Debug/WorkerDebugMiddlewareTest.php b/tests/Unit/Debug/WorkerDebugMiddlewareTest.php new file mode 100644 index 00000000..03e9f0e0 --- /dev/null +++ b/tests/Unit/Debug/WorkerDebugMiddlewareTest.php @@ -0,0 +1,58 @@ +startup(); + $message = new GenericMessage('missing', null); + $handler = new class implements WorkerHandlerInterface { + public function handleWorker(WorkerRequest $request): WorkerRequest + { + throw new RuntimeException('missing handler'); + } + }; + + try { + (new WorkerDebugMiddleware($collector))->processWorker(new WorkerRequest($message, 'queue'), $handler); + } catch (RuntimeException) { + } + + self::assertSame([$message], $collector->getCollected()['processingMessages']['queue']); + self::assertSame(1, $collector->getSummary()['countProcessingMessages']); + } + + public function testOneEventPerInvocationAndInactiveIsNoOp(): void + { + $collector = new QueueCollector(); + $collector->startup(); + $handler = new class implements WorkerHandlerInterface { + public function handleWorker(WorkerRequest $request): WorkerRequest + { + return $request; + } + }; + $middleware = new WorkerDebugMiddleware($collector); + $request = new WorkerRequest(new GenericMessage('test', null), 'queue'); + $middleware->processWorker($request, $handler); + $middleware->processWorker($request, $handler); + self::assertSame(2, $collector->getSummary()['countProcessingMessages']); + + $inactive = new QueueCollector(); + (new WorkerDebugMiddleware($inactive))->processWorker($request, $handler); + self::assertSame([], $inactive->getCollected()); + } +} diff --git a/tests/Unit/Middleware/Consume/ConsumeRequestTest.php b/tests/Unit/Middleware/Consume/ConsumeRequestTest.php index 9ee6aae8..f9c865a1 100644 --- a/tests/Unit/Middleware/Consume/ConsumeRequestTest.php +++ b/tests/Unit/Middleware/Consume/ConsumeRequestTest.php @@ -6,7 +6,6 @@ use Yiisoft\Queue\Message\GenericMessage; use Yiisoft\Queue\Middleware\Consume\ConsumeRequest; -use Yiisoft\Queue\QueueProducerInterface; use Yiisoft\Queue\Tests\TestCase; final class ConsumeRequestTest extends TestCase @@ -14,7 +13,6 @@ final class ConsumeRequestTest extends TestCase public function testImmutable(): void { $message = new GenericMessage('test', 'test'); - $queue = $this->createMock(QueueProducerInterface::class); $consumeRequest = new ConsumeRequest($message, 'test-queue'); $this->assertNotSame($consumeRequest, $consumeRequest->withMessage($message)); diff --git a/tests/Unit/Middleware/FailureHandling/FailureHandlingRequestTest.php b/tests/Unit/Middleware/FailureHandling/FailureHandlingRequestTest.php index 5d497272..e4184768 100644 --- a/tests/Unit/Middleware/FailureHandling/FailureHandlingRequestTest.php +++ b/tests/Unit/Middleware/FailureHandling/FailureHandlingRequestTest.php @@ -6,26 +6,35 @@ use Exception; use Yiisoft\Queue\Message\GenericMessage; +use Yiisoft\Queue\Message\MessageInterface; use Yiisoft\Queue\Middleware\FailureHandling\FailureHandlingRequest; -use Yiisoft\Queue\QueueProducerInterface; use Yiisoft\Queue\Tests\TestCase; final class FailureHandlingRequestTest extends TestCase { public function testImmutable(): void { - $queue = $this->createMock(QueueProducerInterface::class); + $retry = static fn(MessageInterface $message): MessageInterface => $message; $request1 = new FailureHandlingRequest( new GenericMessage('test', null), new Exception('exception 1'), 'test-queue', - $queue, + $retry, ); - $request2 = $request1->withQueueName('other-queue'); + $request2 = $request1->withMessage(new GenericMessage('other', null)); $request3 = $request1->withException(new Exception('exception 2')); + $requestWithRetry = new FailureHandlingRequest( + $request1->getMessage(), + $request1->getException(), + $request1->getQueueName(), + $retry, + ); $request4 = $request1->withMessage(new GenericMessage('test2', null)); $this->assertNotSame($request1, $request2); + $this->assertSame('test-queue', $request1->getQueueName()); + $this->assertFalse(method_exists($request1, 'withQueueName')); + $this->assertSame($retry, $requestWithRetry->getRetry()); $this->assertNotSame($request1, $request3); $this->assertEquals($request1->getException()->getMessage(), 'exception 1'); diff --git a/tests/Unit/Middleware/FailureHandling/Implementation/ExponentialDelayMiddlewareTest.php b/tests/Unit/Middleware/FailureHandling/Implementation/ExponentialDelayMiddlewareTest.php index 25a122b9..88379cff 100644 --- a/tests/Unit/Middleware/FailureHandling/Implementation/ExponentialDelayMiddlewareTest.php +++ b/tests/Unit/Middleware/FailureHandling/Implementation/ExponentialDelayMiddlewareTest.php @@ -7,12 +7,21 @@ use Exception; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; +use Psr\Log\NullLogger; +use RuntimeException; +use Yiisoft\Queue\AsyncQueueProducer; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; +use Yiisoft\Queue\Provider\InvalidQueueConfigException; +use Yiisoft\Queue\Provider\QueueProducerProviderInterface; +use Yiisoft\Queue\Stubs\InMemoryAdapter; +use Yiisoft\Test\Support\Container\SimpleContainer; use Yiisoft\Queue\Message\GenericMessage; +use Yiisoft\Queue\Message\MessageInterface; use Yiisoft\Queue\Middleware\FailureHandling\FailureEnvelope; use Yiisoft\Queue\Middleware\FailureHandling\FailureHandlingRequest; use Yiisoft\Queue\Middleware\FailureHandling\Implementation\ExponentialDelayMiddleware; use Yiisoft\Queue\Middleware\FailureHandling\FailureHandlerInterface; -use Yiisoft\Queue\QueueProducerInterface; use Yiisoft\Queue\Message\DelayEnvelope; use Yiisoft\Queue\Tests\TestCase; @@ -119,7 +128,7 @@ public static function constructorRequirementsProvider(): array #[DataProvider('constructorRequirementsProvider')] public function testConstructorRequirements(bool $success, array $arguments): void { - $arguments[] = $this->createMock(QueueProducerInterface::class); + $arguments[] = static fn(MessageInterface $message): MessageInterface => $message; if (!$success) { $this->expectException(InvalidArgumentException::class); @@ -132,19 +141,18 @@ public function testConstructorRequirements(bool $success, array $arguments): vo public function testPipelineSuccess(): void { $message = new GenericMessage('test', null); - $queue = $this->createMock(QueueProducerInterface::class); - $queue->method('push')->willReturnArgument(0); + $retry = static fn(MessageInterface $message): MessageInterface => $message; $middleware = new ExponentialDelayMiddleware( 'test', 1, 1, 1, 1, - $queue, + $retry, ); $nextHandler = $this->createMock(FailureHandlerInterface::class); $nextHandler->expects(self::never())->method('handleFailure'); - $request = new FailureHandlingRequest($message, new Exception('test'), 'test-queue', $queue); + $request = new FailureHandlingRequest($message, new Exception('test'), 'test-queue', $retry); $result = $middleware->processFailure($request, $nextHandler); self::assertNotEquals($request, $result); @@ -166,19 +174,97 @@ public function testPipelineFailure(): void 'test', null, ))->withMeta([FailureEnvelope::META_FAILURE => [ExponentialDelayMiddleware::META_KEY_ATTEMPTS . '-test' => 2]]); - $queue = $this->createMock(QueueProducerInterface::class); + $retry = static fn(MessageInterface $message): MessageInterface => $message; $middleware = new ExponentialDelayMiddleware( 'test', 1, 1, 1, 1, - $queue, + $retry, ); $nextHandler = $this->createMock(FailureHandlerInterface::class); $exception = new Exception('test'); $nextHandler->expects(self::once())->method('handleFailure')->willThrowException($exception); - $request = new FailureHandlingRequest($message, $exception, 'test-queue', $queue); + $request = new FailureHandlingRequest($message, $exception, 'test-queue', $retry); $middleware->processFailure($request, $nextHandler); } + + public function testMissingProducerCapability(): void + { + $middleware = new ExponentialDelayMiddleware('test', 1, 1, 1, 1); + $request = new FailureHandlingRequest( + new GenericMessage('test', null), + new Exception('test'), + 'test-queue', + ); + + $this->expectException(InvalidQueueConfigException::class); + $this->expectExceptionMessage('configure a producer target or QueueProducerProviderInterface'); + $middleware->processFailure($request, $this->createMock(FailureHandlerInterface::class)); + } + + public function testProducerProviderIsUsedForRetry(): void + { + $producer = $this->getProducer(); + $provider = $this->createMock(QueueProducerProviderInterface::class); + $provider->expects(self::once())->method('getProducer')->with('test-queue')->willReturn($producer); + $middleware = new ExponentialDelayMiddleware( + 'test', + 1, + 1, + 1, + 1, + producerProvider: $provider, + ); + $request = new FailureHandlingRequest( + new GenericMessage('test', null), + new Exception('test'), + 'test-queue', + ); + + $result = $middleware->processFailure($request, $this->createMock(FailureHandlerInterface::class)); + + self::assertNotSame($request->getMessage(), $result->getMessage()); + } + + public function testUnavailableProducerProviderIsReported(): void + { + $provider = $this->createMock(QueueProducerProviderInterface::class); + $provider->expects(self::once()) + ->method('getProducer') + ->with('test-queue') + ->willThrowException(new RuntimeException('missing producer')); + $middleware = new ExponentialDelayMiddleware( + 'test', + 1, + 1, + 1, + 1, + producerProvider: $provider, + ); + $request = new FailureHandlingRequest( + new GenericMessage('test', null), + new Exception('test'), + 'test-queue', + ); + + try { + $middleware->processFailure($request, $this->createMock(FailureHandlerInterface::class)); + self::fail('Expected an invalid queue configuration exception.'); + } catch (InvalidQueueConfigException $exception) { + self::assertStringContainsString('no producer capability is available', $exception->getMessage()); + self::assertInstanceOf(RuntimeException::class, $exception->getPrevious()); + } + } + + private function getProducer(): AsyncQueueProducer + { + return new AsyncQueueProducer( + new NullLogger(), + new PushMiddlewareConfig(new PushMiddlewareFactory(new SimpleContainer())), + new InMemoryAdapter(), + 'test-queue', + ); + } } diff --git a/tests/Unit/Middleware/FailureHandling/Implementation/SendAgainMiddlewareTest.php b/tests/Unit/Middleware/FailureHandling/Implementation/SendAgainMiddlewareTest.php index 519e0544..0d4f6905 100644 --- a/tests/Unit/Middleware/FailureHandling/Implementation/SendAgainMiddlewareTest.php +++ b/tests/Unit/Middleware/FailureHandling/Implementation/SendAgainMiddlewareTest.php @@ -5,9 +5,19 @@ namespace Yiisoft\Queue\Tests\Unit\Middleware\FailureHandling\Implementation; use Exception; +use Closure; +use InvalidArgumentException; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; +use Psr\Log\NullLogger; use RuntimeException; +use Yiisoft\Queue\AsyncQueueProducer; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; +use Yiisoft\Queue\Provider\InvalidQueueConfigException; +use Yiisoft\Queue\Provider\QueueProducerProviderInterface; +use Yiisoft\Queue\Stubs\InMemoryAdapter; +use Yiisoft\Test\Support\Container\SimpleContainer; use Yiisoft\Queue\Message\GenericMessage; use Yiisoft\Queue\Message\MessageInterface; use Yiisoft\Queue\Middleware\FailureHandling\FailureEnvelope; @@ -16,7 +26,6 @@ use Yiisoft\Queue\Middleware\FailureHandling\Implementation\SendAgainMiddleware; use Yiisoft\Queue\Middleware\FailureHandling\FailureHandlerInterface; use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareInterface; -use Yiisoft\Queue\QueueProducerInterface; use Yiisoft\Queue\Tests\TestCase; final class SendAgainMiddlewareTest extends TestCase @@ -151,9 +160,9 @@ public function testQueueSendingStrategies( } $handler = $this->getHandler($metaResult, $suites); - $queue = $this->getPreparedQueue($metaResult, $suites); + $retry = $this->getRetry($metaResult, $suites); - $strategy = $this->getStrategy($strategyName, $queue); + $strategy = $this->getStrategy($strategyName, $retry); $request = new FailureHandlingRequest( (new GenericMessage( 'test', @@ -161,24 +170,88 @@ public function testQueueSendingStrategies( ))->withMeta([FailureEnvelope::META_FAILURE => $metaInitial]), new Exception('testException'), 'test-queue', - $queue, + $retry, ); $result = $strategy->processFailure($request, $handler); self::assertInstanceOf(FailureHandlingRequest::class, $result); } - private function getStrategy(string $strategyName, QueueProducerInterface $queue): FailureMiddlewareInterface + public function testConstructorRejectsNonPositiveMaxAttempts(): void + { + $this->expectException(InvalidArgumentException::class); + + new SendAgainMiddleware('test', 0); + } + + public function testMissingProducerCapability(): void + { + $middleware = new SendAgainMiddleware('test', 1); + $request = new FailureHandlingRequest( + new GenericMessage('test', null), + new Exception('test'), + 'test-queue', + ); + + $this->expectException(InvalidQueueConfigException::class); + $this->expectExceptionMessage('configure a producer target or QueueProducerProviderInterface'); + $middleware->processFailure($request, $this->createMock(FailureHandlerInterface::class)); + } + + public function testProducerProviderIsUsedForRetry(): void + { + $producer = $this->getProducer(); + $provider = $this->createMock(QueueProducerProviderInterface::class); + $provider->expects(self::once())->method('getProducer')->with('test-queue')->willReturn($producer); + $middleware = new SendAgainMiddleware('test', 1, producerProvider: $provider); + $request = new FailureHandlingRequest( + new GenericMessage('test', null), + new Exception('test'), + 'test-queue', + ); + + $result = $middleware->processFailure($request, $this->createMock(FailureHandlerInterface::class)); + + self::assertNotSame($request->getMessage(), $result->getMessage()); + } + + public function testUnavailableProducerProviderIsReported(): void + { + $provider = $this->createMock(QueueProducerProviderInterface::class); + $provider->expects(self::once()) + ->method('getProducer') + ->with('test-queue') + ->willThrowException(new RuntimeException('missing producer')); + $middleware = new SendAgainMiddleware('test', 1, producerProvider: $provider); + $request = new FailureHandlingRequest( + new GenericMessage('test', null), + new Exception('test'), + 'test-queue', + ); + + try { + $middleware->processFailure($request, $this->createMock(FailureHandlerInterface::class)); + self::fail('Expected an invalid queue configuration exception.'); + } catch (InvalidQueueConfigException $exception) { + self::assertStringContainsString('no producer capability is available', $exception->getMessage()); + self::assertInstanceOf(RuntimeException::class, $exception->getPrevious()); + } + } + + /** + * @param Closure(MessageInterface): MessageInterface $retry + */ + private function getStrategy(string $strategyName, Closure $retry): FailureMiddlewareInterface { return match ($strategyName) { - SendAgainMiddleware::class => new SendAgainMiddleware('', 2, $queue), + SendAgainMiddleware::class => new SendAgainMiddleware('', 2, $retry), ExponentialDelayMiddleware::class => new ExponentialDelayMiddleware( 'test', 2, self::EXPONENTIAL_STRATEGY_DELAY_INITIAL, self::EXPONENTIAL_STRATEGY_DELAY_MAXIMUM, self::EXPONENTIAL_STRATEGY_EXPONENT, - $queue, + $retry, ), default => throw new RuntimeException('Unknown strategy'), }; @@ -201,19 +274,26 @@ private function getHandler(array $metaResult, bool $suites): FailureHandlerInte return $handler; } - private function getPreparedQueue(array $metaResult, bool $suites): QueueProducerInterface + /** + * @return Closure(MessageInterface): MessageInterface + */ + private function getRetry(array $metaResult, bool $suites): Closure { - $queueAssertion = static function (MessageInterface $message) use ($metaResult): MessageInterface { - Assert::assertEquals($metaResult, $message->getMeta()[FailureEnvelope::META_FAILURE] ?? []); - + return function (MessageInterface $message) use ($metaResult, $suites): MessageInterface { + if ($suites) { + Assert::assertEquals($metaResult, $message->getMeta()[FailureEnvelope::META_FAILURE] ?? []); + } return $message; }; + } - $queue = $this->createMock(QueueProducerInterface::class); - $queue->expects($suites ? self::once() : self::never()) - ->method('push') - ->willReturnCallback($queueAssertion); - - return $queue; + private function getProducer(): AsyncQueueProducer + { + return new AsyncQueueProducer( + new NullLogger(), + new PushMiddlewareConfig(new PushMiddlewareFactory(new SimpleContainer())), + new InMemoryAdapter(), + 'test-queue', + ); } } diff --git a/tests/Unit/Middleware/Push/AdapterPushHandlerTest.php b/tests/Unit/Middleware/Push/AdapterPushHandlerTest.php index b8e13c5b..21ad6c98 100644 --- a/tests/Unit/Middleware/Push/AdapterPushHandlerTest.php +++ b/tests/Unit/Middleware/Push/AdapterPushHandlerTest.php @@ -5,20 +5,38 @@ namespace Yiisoft\Queue\Tests\Unit\Middleware\Push; use PHPUnit\Framework\TestCase; +use Yiisoft\Queue\Adapter\AdapterInterface; use Yiisoft\Queue\Message\GenericMessage; use Yiisoft\Queue\Middleware\Push\AdapterPushHandler; +use Yiisoft\Queue\Middleware\Push\PushRequest; use Yiisoft\Queue\Stubs\InMemoryAdapter; final class AdapterPushHandlerTest extends TestCase { + public function testHandlePushReturnsAdapterEnrichedMessage(): void + { + $adapter = $this->createMock(AdapterInterface::class); + $message = new GenericMessage('handler', 'data'); + $enriched = $message->withMeta(['adapter-id' => 'id']); + $adapter->expects(self::once())->method('push')->with($message)->willReturn($enriched); + + $result = (new AdapterPushHandler($adapter))->handlePush(new PushRequest($message, 'queue')); + + self::assertSame($enriched, $result->getMessage()); + self::assertSame('queue', $result->getQueueName()); + } + public function testHandlePushUsesAdapter(): void { $adapter = new InMemoryAdapter(); $handler = new AdapterPushHandler($adapter); $message = new GenericMessage('handler', 'data'); - $handler->handlePush($message); + $request = new PushRequest($message, 'test-queue'); + $result = $handler->handlePush($request); self::assertSame([$message], $adapter->getMessagesList()); + self::assertNotSame($message, $result->getMessage()); + self::assertSame('test-queue', $result->getQueueName()); } } diff --git a/tests/Unit/Middleware/Push/Implementation/IdMiddlewareTest.php b/tests/Unit/Middleware/Push/Implementation/IdMiddlewareTest.php index d3ffb71f..ebc2ee70 100644 --- a/tests/Unit/Middleware/Push/Implementation/IdMiddlewareTest.php +++ b/tests/Unit/Middleware/Push/Implementation/IdMiddlewareTest.php @@ -9,6 +9,7 @@ use Yiisoft\Queue\Message\GenericMessage; use Yiisoft\Queue\Middleware\Push\Implementation\IdMiddleware; use Yiisoft\Queue\Middleware\Push\PushHandlerInterface; +use Yiisoft\Queue\Middleware\Push\PushRequest; final class IdMiddlewareTest extends TestCase { @@ -22,13 +23,14 @@ public function testWithId(): void ->willReturnArgument(0); $middleware = new IdMiddleware(); - $result = $middleware->processPush($message, $handler); + $result = $middleware->processPush(new PushRequest($message, 'test-queue'), $handler); - $this->assertSame($message, $result); - $this->assertNotInstanceOf(IdEnvelope::class, $result); - $this->assertEquals('test-id', $result->getMeta()[IdEnvelope::META_ID]); - $this->assertSame($message->getPayload(), $result->getPayload()); - $this->assertSame($message->getType(), $result->getType()); + $this->assertSame($message, $result->getMessage()); + $this->assertSame('test-queue', $result->getQueueName()); + $this->assertNotInstanceOf(IdEnvelope::class, $result->getMessage()); + $this->assertEquals('test-id', $result->getMessage()->getMeta()[IdEnvelope::META_ID]); + $this->assertSame($message->getPayload(), $result->getMessage()->getPayload()); + $this->assertSame($message->getType(), $result->getMessage()->getType()); } public function testWithoutId(): void @@ -41,13 +43,13 @@ public function testWithoutId(): void ->willReturnArgument(0); $middleware = new IdMiddleware(); - $result = $middleware->processPush($message, $handler); + $result = $middleware->processPush(new PushRequest($message, 'test-queue'), $handler); - $this->assertInstanceOf(IdEnvelope::class, $result); - $this->assertNotSame($message, $result); - $this->assertNotEmpty($result->getMeta()[IdEnvelope::META_ID] ?? null); - $this->assertSame($message->getPayload(), $result->getPayload()); - $this->assertSame($message->getType(), $result->getType()); + $this->assertInstanceOf(IdEnvelope::class, $result->getMessage()); + $this->assertNotSame($message, $result->getMessage()); + $this->assertNotEmpty($result->getMessage()->getMeta()[IdEnvelope::META_ID] ?? null); + $this->assertSame($message->getPayload(), $result->getMessage()->getPayload()); + $this->assertSame($message->getType(), $result->getMessage()->getType()); } public function testWithEmptyId(): void @@ -60,13 +62,13 @@ public function testWithEmptyId(): void ->willReturnArgument(0); $middleware = new IdMiddleware(); - $result = $middleware->processPush($message, $handler); + $result = $middleware->processPush(new PushRequest($message, 'test-queue'), $handler); - $this->assertInstanceOf(IdEnvelope::class, $result); - $this->assertNotSame($message, $result); - $this->assertNotEmpty($result->getMeta()[IdEnvelope::META_ID] ?? null); - $this->assertNotSame('', $result->getMeta()[IdEnvelope::META_ID]); - $this->assertSame($message->getPayload(), $result->getPayload()); - $this->assertSame($message->getType(), $result->getType()); + $this->assertInstanceOf(IdEnvelope::class, $result->getMessage()); + $this->assertNotSame($message, $result->getMessage()); + $this->assertNotEmpty($result->getMessage()->getMeta()[IdEnvelope::META_ID] ?? null); + $this->assertNotSame('', $result->getMessage()->getMeta()[IdEnvelope::META_ID]); + $this->assertSame($message->getPayload(), $result->getMessage()->getPayload()); + $this->assertSame($message->getType(), $result->getMessage()->getType()); } } diff --git a/tests/Unit/Middleware/Push/MiddlewareDispatcherTest.php b/tests/Unit/Middleware/Push/MiddlewareDispatcherTest.php index 21ff6088..00ae6d3a 100644 --- a/tests/Unit/Middleware/Push/MiddlewareDispatcherTest.php +++ b/tests/Unit/Middleware/Push/MiddlewareDispatcherTest.php @@ -12,6 +12,7 @@ use Yiisoft\Queue\Message\GenericMessage; use Yiisoft\Queue\Message\MessageInterface; use Yiisoft\Queue\Middleware\Push\PushHandlerInterface; +use Yiisoft\Queue\Middleware\Push\PushRequest; use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; use Yiisoft\Queue\Middleware\Push\PushMiddlewareDispatcher; use Yiisoft\Queue\Stubs\InMemoryAdapter; @@ -26,14 +27,14 @@ public function testCallableMiddlewareCalled(): void $dispatcher = $this->createDispatcher()->withMiddlewares( [ - static function (MessageInterface $message, PushHandlerInterface $handler): MessageInterface { - return new GenericMessage('test', 'New closure test data'); + static function (PushRequest $request, PushHandlerInterface $handler): PushRequest { + return $request->withMessage(new GenericMessage('test', 'New closure test data')); }, ], ); - $result = $dispatcher->dispatch($message); - $this->assertSame('New closure test data', $result->getPayload()); + $result = $dispatcher->dispatch(new PushRequest($message, 'test-queue')); + $this->assertSame('New closure test data', $result->getMessage()->getPayload()); } public function testArrayMiddlewareCallableDefinition(): void @@ -45,8 +46,8 @@ public function testArrayMiddlewareCallableDefinition(): void ], ); $dispatcher = $this->createDispatcher($container)->withMiddlewares([[TestCallableMiddleware::class, 'index']]); - $result = $dispatcher->dispatch($message); - $this->assertSame('New test data', $result->getPayload()); + $result = $dispatcher->dispatch(new PushRequest($message, 'test-queue')); + $this->assertSame('New test data', $result->getMessage()->getPayload()); } public function testFactoryArrayDefinition(): void @@ -58,38 +59,48 @@ public function testFactoryArrayDefinition(): void '__construct()' => ['message' => 'New test data from the definition'], ]; $dispatcher = $this->createDispatcher($container)->withMiddlewares([$definition]); - $result = $dispatcher->dispatch($message); - $this->assertSame('New test data from the definition', $result->getPayload()); + $result = $dispatcher->dispatch(new PushRequest($message, 'test-queue')); + $this->assertSame('New test data from the definition', $result->getMessage()->getPayload()); } public function testMiddlewareFullStackCalled(): void { $message = $this->getMessage(); - $middleware1 = static function (MessageInterface $message, PushHandlerInterface $handler): MessageInterface { - return $handler->handlePush(new GenericMessage($message->getType(), 'new test data')); + $middleware1 = static function (PushRequest $request, PushHandlerInterface $handler): PushRequest { + return $handler->handlePush($request->withMessage(new GenericMessage($request->getMessage()->getType(), 'new test data'))); }; - $middleware2 = static function (MessageInterface $message, PushHandlerInterface $handler): MessageInterface { - return $handler->handlePush($message); + $middleware2 = static function (PushRequest $request, PushHandlerInterface $handler): PushRequest { + return $handler->handlePush($request); }; $dispatcher = $this->createDispatcher()->withMiddlewares([$middleware1, $middleware2]); - $result = $dispatcher->dispatch($message); - $this->assertSame('new test data', $result->getPayload()); + $result = $dispatcher->dispatch(new PushRequest($message, 'test-queue')); + $this->assertSame('new test data', $result->getMessage()->getPayload()); + } + + public function testCachedStackPreservesQueueIdentity(): void + { + $dispatcher = $this->createDispatcher(); + $first = $dispatcher->dispatch(new PushRequest($this->getMessage(), 'first-queue')); + $second = $dispatcher->dispatch(new PushRequest($this->getMessage(), 'second-queue')); + + self::assertSame('first-queue', $first->getQueueName()); + self::assertSame('second-queue', $second->getQueueName()); } public function testMiddlewareStackInterrupted(): void { $message = $this->getMessage(); - $middleware1 = static fn(MessageInterface $message, PushHandlerInterface $handler): MessageInterface => new GenericMessage($message->getType(), 'first'); - $middleware2 = static fn(MessageInterface $message, PushHandlerInterface $handler): MessageInterface => new GenericMessage($message->getType(), 'second'); + $middleware1 = static fn(PushRequest $request, PushHandlerInterface $handler): PushRequest => $request->withMessage(new GenericMessage($request->getMessage()->getType(), 'first')); + $middleware2 = static fn(PushRequest $request, PushHandlerInterface $handler): PushRequest => $request->withMessage(new GenericMessage($request->getMessage()->getType(), 'second')); $dispatcher = $this->createDispatcher()->withMiddlewares([$middleware1, $middleware2]); - $result = $dispatcher->dispatch($message); - $this->assertSame('first', $result->getPayload()); + $result = $dispatcher->dispatch(new PushRequest($message, 'test-queue')); + $this->assertSame('first', $result->getMessage()->getPayload()); } public static function dataHasMiddlewares(): array @@ -128,12 +139,12 @@ public function testResetStackOnWithMiddlewares(): void $dispatcher = $this ->createDispatcher($container) ->withMiddlewares([[TestCallableMiddleware::class, 'index']]); - $dispatcher->dispatch($message); + $dispatcher->dispatch(new PushRequest($message, 'test-queue')); $dispatcher = $dispatcher->withMiddlewares([TestMiddleware::class]); - $result = $dispatcher->dispatch($message); + $result = $dispatcher->dispatch(new PushRequest($message, 'test-queue')); - self::assertSame('New middleware test data', $result->getPayload()); + self::assertSame('New middleware test data', $result->getMessage()->getPayload()); } private function createDispatcher( @@ -145,9 +156,9 @@ private function createDispatcher( new PushMiddlewareFactory($container), [], new class implements PushHandlerInterface { - public function handlePush(MessageInterface $message): MessageInterface + public function handlePush(PushRequest $request): PushRequest { - return $message; + return $request; } }, ); diff --git a/tests/Unit/Middleware/Push/MiddlewareFactoryTest.php b/tests/Unit/Middleware/Push/MiddlewareFactoryTest.php index 6a30802d..0afa540e 100644 --- a/tests/Unit/Middleware/Push/MiddlewareFactoryTest.php +++ b/tests/Unit/Middleware/Push/MiddlewareFactoryTest.php @@ -13,6 +13,7 @@ use Yiisoft\Queue\Message\MessageInterface; use Yiisoft\Queue\Middleware\InvalidMiddlewareDefinitionException; use Yiisoft\Queue\Middleware\Push\PushHandlerInterface; +use Yiisoft\Queue\Middleware\Push\PushRequest; use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactoryInterface; use Yiisoft\Queue\Middleware\Push\PushMiddlewareInterface; @@ -39,9 +40,9 @@ public function testCreateCallableFromArray(): void self::assertSame( 'New test data', $middleware->processPush( - $this->getMessage(), + $this->getRequest(), $this->createMock(PushHandlerInterface::class), - )->getPayload(), + )->getMessage()->getPayload(), ); } @@ -49,16 +50,16 @@ public function testCreateFromClosureResponse(): void { $container = $this->getContainer([TestCallableMiddleware::class => new TestCallableMiddleware()]); $middleware = $this->getMiddlewareFactory($container)->createPushMiddleware( - static function (): MessageInterface { - return new GenericMessage('test', 'test data'); + static function (PushRequest $request): PushRequest { + return $request->withMessage(new GenericMessage('test', 'test data')); }, ); self::assertSame( 'test data', $middleware->processPush( - $this->getMessage(), + $this->getRequest(), $this->createMock(PushHandlerInterface::class), - )->getPayload(), + )->getMessage()->getPayload(), ); } @@ -73,9 +74,9 @@ static function (): PushMiddlewareInterface { self::assertSame( 'New middleware test data', $middleware->processPush( - $this->getMessage(), + $this->getRequest(), $this->createMock(PushHandlerInterface::class), - )->getPayload(), + )->getMessage()->getPayload(), ); } @@ -87,9 +88,9 @@ public function testCreateWithUseParamsMiddleware(): void self::assertSame( 'New middleware test data', $middleware->processPush( - $this->getMessage(), + $this->getRequest(), $this->getRequestHandler(), - )->getPayload(), + )->getMessage()->getPayload(), ); } @@ -101,9 +102,9 @@ public function testCreateWithTestCallableMiddleware(): void self::assertSame( 'New test data', $middleware->processPush( - $this->getMessage(), + $this->getRequest(), $this->getRequestHandler(), - )->getPayload(), + )->getMessage()->getPayload(), ); } @@ -115,9 +116,9 @@ public function testCreateFromStringCallable(): void self::assertSame( 'String callable data', $middleware->processPush( - $this->getMessage(), + $this->getRequest(), $this->createMock(PushHandlerInterface::class), - )->getPayload(), + )->getMessage()->getPayload(), ); } @@ -129,9 +130,9 @@ public function testCreateFromCallableObject(): void self::assertSame( 'Callable object data', $middleware->processPush( - $this->getMessage(), + $this->getRequest(), $this->createMock(PushHandlerInterface::class), - )->getPayload(), + )->getMessage()->getPayload(), ); } @@ -165,7 +166,7 @@ public function testInvalidMiddlewareWithWrongController(): void $this->expectException(InvalidMiddlewareDefinitionException::class); $middleware->processPush( - $this->getMessage(), + $this->getRequest(), $this->createMock(PushHandlerInterface::class), ); } @@ -185,9 +186,9 @@ private function getContainer(array $instances = []): ContainerInterface private function getRequestHandler(): PushHandlerInterface { return new class implements PushHandlerInterface { - public function handlePush(MessageInterface $message): MessageInterface + public function handlePush(PushRequest $request): PushRequest { - return $message; + return $request; } }; } @@ -196,4 +197,9 @@ private function getMessage(): MessageInterface { return new GenericMessage('handler', 'data'); } + + private function getRequest(): PushRequest + { + return new PushRequest($this->getMessage(), 'test-queue'); + } } diff --git a/tests/Unit/Middleware/Push/PushRequestTest.php b/tests/Unit/Middleware/Push/PushRequestTest.php new file mode 100644 index 00000000..f649b5df --- /dev/null +++ b/tests/Unit/Middleware/Push/PushRequestTest.php @@ -0,0 +1,25 @@ +withMessage(new GenericMessage('other', null)); + + self::assertSame('42', $request->getQueueName()); + self::assertSame($message, $request->getMessage()); + self::assertNotSame($request, $copy); + self::assertSame('42', $copy->getQueueName()); + self::assertFalse(method_exists($request, 'withQueueName')); + } +} diff --git a/tests/Unit/Middleware/Push/Support/CallableObjectMiddleware.php b/tests/Unit/Middleware/Push/Support/CallableObjectMiddleware.php index 996665b6..71966a58 100644 --- a/tests/Unit/Middleware/Push/Support/CallableObjectMiddleware.php +++ b/tests/Unit/Middleware/Push/Support/CallableObjectMiddleware.php @@ -5,12 +5,12 @@ namespace Yiisoft\Queue\Tests\Unit\Middleware\Push\Support; use Yiisoft\Queue\Message\GenericMessage; -use Yiisoft\Queue\Message\MessageInterface; +use Yiisoft\Queue\Middleware\Push\PushRequest; final class CallableObjectMiddleware { - public function __invoke(MessageInterface $message): MessageInterface + public function __invoke(PushRequest $request): PushRequest { - return new GenericMessage('test', 'Callable object data'); + return $request->withMessage(new GenericMessage('test', 'Callable object data')); } } diff --git a/tests/Unit/Middleware/Push/Support/StringCallableMiddleware.php b/tests/Unit/Middleware/Push/Support/StringCallableMiddleware.php index f0c11636..c9bd5506 100644 --- a/tests/Unit/Middleware/Push/Support/StringCallableMiddleware.php +++ b/tests/Unit/Middleware/Push/Support/StringCallableMiddleware.php @@ -5,12 +5,12 @@ namespace Yiisoft\Queue\Tests\Unit\Middleware\Push\Support; use Yiisoft\Queue\Message\GenericMessage; -use Yiisoft\Queue\Message\MessageInterface; +use Yiisoft\Queue\Middleware\Push\PushRequest; final class StringCallableMiddleware { - public static function handle(MessageInterface $message): MessageInterface + public static function handle(PushRequest $request): PushRequest { - return new GenericMessage('test', 'String callable data'); + return $request->withMessage(new GenericMessage('test', 'String callable data')); } } diff --git a/tests/Unit/Middleware/Push/Support/TestCallableMiddleware.php b/tests/Unit/Middleware/Push/Support/TestCallableMiddleware.php index a8219ac8..ddaa66ef 100644 --- a/tests/Unit/Middleware/Push/Support/TestCallableMiddleware.php +++ b/tests/Unit/Middleware/Push/Support/TestCallableMiddleware.php @@ -5,12 +5,12 @@ namespace Yiisoft\Queue\Tests\Unit\Middleware\Push\Support; use Yiisoft\Queue\Message\GenericMessage; -use Yiisoft\Queue\Message\MessageInterface; +use Yiisoft\Queue\Middleware\Push\PushRequest; final class TestCallableMiddleware { - public function index(MessageInterface $message): MessageInterface + public function index(PushRequest $request): PushRequest { - return new GenericMessage('test', 'New test data'); + return $request->withMessage(new GenericMessage('test', 'New test data')); } } diff --git a/tests/Unit/Middleware/Push/Support/TestMiddleware.php b/tests/Unit/Middleware/Push/Support/TestMiddleware.php index ba942661..daa1d13c 100644 --- a/tests/Unit/Middleware/Push/Support/TestMiddleware.php +++ b/tests/Unit/Middleware/Push/Support/TestMiddleware.php @@ -5,7 +5,7 @@ namespace Yiisoft\Queue\Tests\Unit\Middleware\Push\Support; use Yiisoft\Queue\Message\GenericMessage; -use Yiisoft\Queue\Message\MessageInterface; +use Yiisoft\Queue\Middleware\Push\PushRequest; use Yiisoft\Queue\Middleware\Push\PushHandlerInterface; use Yiisoft\Queue\Middleware\Push\PushMiddlewareInterface; @@ -13,8 +13,8 @@ final class TestMiddleware implements PushMiddlewareInterface { public function __construct(private readonly string $message = 'New middleware test data') {} - public function processPush(MessageInterface $message, PushHandlerInterface $handler): MessageInterface + public function processPush(PushRequest $request, PushHandlerInterface $handler): PushRequest { - return new GenericMessage('test', $this->message); + return $request->withMessage(new GenericMessage('test', $this->message)); } } diff --git a/tests/Unit/Middleware/Worker/WorkerMiddlewareDispatcherTest.php b/tests/Unit/Middleware/Worker/WorkerMiddlewareDispatcherTest.php new file mode 100644 index 00000000..7cce8a04 --- /dev/null +++ b/tests/Unit/Middleware/Worker/WorkerMiddlewareDispatcherTest.php @@ -0,0 +1,81 @@ +handleWorker($request); + $events[] = 'first-after'; + return $result; + }, + static function (WorkerRequest $request, WorkerHandlerInterface $next) use (&$events): WorkerRequest { + $events[] = 'second'; + return $request->withMessage(new GenericMessage('done', null)); + }, + ]); + $finish = new class implements WorkerHandlerInterface { + public function handleWorker(WorkerRequest $request): WorkerRequest + { + return $request->withMessage(new GenericMessage('finish', null)); + } + }; + + $result = $dispatcher->dispatch(new WorkerRequest(new GenericMessage('start', null), 'queue'), $finish); + + self::assertSame(['first-before', 'second', 'first-after'], $events); + self::assertSame('done', $result->getMessage()->getType()); + } + + public function testWithMiddlewaresClonesAndRebuildsStack(): void + { + $factory = new WorkerMiddlewareFactory(new SimpleContainer()); + $dispatcher = new WorkerMiddlewareDispatcher($factory, [ + static fn(WorkerRequest $request, WorkerHandlerInterface $handler): WorkerRequest => $request->withMessage( + new GenericMessage('original', null), + ), + ]); + $finish = new class implements WorkerHandlerInterface { + public function handleWorker(WorkerRequest $request): WorkerRequest + { + return $request; + } + }; + $request = new WorkerRequest(new GenericMessage('start', null), 'queue'); + + self::assertSame('original', $dispatcher->dispatch($request, $finish)->getMessage()->getType()); + + $replacement = $dispatcher->withMiddlewares([ + static fn(WorkerRequest $request, WorkerHandlerInterface $handler): WorkerRequest => $request->withMessage( + new GenericMessage('replacement', null), + ), + ]); + + self::assertNotSame($dispatcher, $replacement); + self::assertSame('original', $dispatcher->dispatch($request, $finish)->getMessage()->getType()); + self::assertSame('replacement', $replacement->dispatch($request, $finish)->getMessage()->getType()); + } + + public function testRequestNormalizesQueueAndHasNoQueueMutator(): void + { + $request = new WorkerRequest(new GenericMessage('test', null), '7'); + self::assertSame('7', $request->getQueueName()); + self::assertFalse(method_exists($request, 'withQueueName')); + } +} diff --git a/tests/Unit/Middleware/Worker/WorkerMiddlewareFactoryTest.php b/tests/Unit/Middleware/Worker/WorkerMiddlewareFactoryTest.php new file mode 100644 index 00000000..5c4033a5 --- /dev/null +++ b/tests/Unit/Middleware/Worker/WorkerMiddlewareFactoryTest.php @@ -0,0 +1,66 @@ +expectException(InvalidMiddlewareDefinitionException::class); + + (new WorkerMiddlewareFactory(new SimpleContainer()))->createWorkerMiddleware(42); + } + + public function testCallableReturningMiddlewareIsDelegated(): void + { + $workerMiddleware = new class implements WorkerMiddlewareInterface { + public function processWorker(WorkerRequest $request, WorkerHandlerInterface $handler): WorkerRequest + { + return $request->withMessage(new GenericMessage('middleware', null)); + } + }; + $middleware = (new WorkerMiddlewareFactory(new SimpleContainer()))->createWorkerMiddleware( + static function () use ($workerMiddleware): WorkerMiddlewareInterface { + return $workerMiddleware; + }, + ); + $handler = new class implements WorkerHandlerInterface { + public function handleWorker(WorkerRequest $request): WorkerRequest + { + return $request; + } + }; + + $result = $middleware->processWorker(new WorkerRequest(new GenericMessage('start', null), 'queue'), $handler); + + self::assertSame('middleware', $result->getMessage()->getType()); + } + + public function testCallableReturningInvalidResponseIsRejected(): void + { + $middleware = (new WorkerMiddlewareFactory(new SimpleContainer()))->createWorkerMiddleware( + static fn(): object => new stdClass(), + ); + $handler = new class implements WorkerHandlerInterface { + public function handleWorker(WorkerRequest $request): WorkerRequest + { + return $request; + } + }; + + $this->expectException(InvalidMiddlewareDefinitionException::class); + $middleware->processWorker(new WorkerRequest(new GenericMessage('start', null), 'queue'), $handler); + } +} diff --git a/tests/Unit/Provider/CompositeQueueProviderTest.php b/tests/Unit/Provider/CompositeQueueProviderTest.php index 2a1a2db1..a77e0e05 100644 --- a/tests/Unit/Provider/CompositeQueueProviderTest.php +++ b/tests/Unit/Provider/CompositeQueueProviderTest.php @@ -5,31 +5,67 @@ namespace Yiisoft\Queue\Tests\Unit\Provider; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Yiisoft\Queue\Cli\SimpleLoop; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactory; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareFactory; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; use Yiisoft\Queue\Provider\CompositeQueueProvider; use Yiisoft\Queue\Provider\PredefinedQueueProvider; +use Yiisoft\Queue\SyncQueueProducer; +use Yiisoft\Queue\Worker\Worker; +use Yiisoft\Queue\Message\Handler\HandlerResolver; use Yiisoft\Queue\Provider\QueueNotFoundException; -use Yiisoft\Queue\Stubs\StubQueueConsumer; -use Yiisoft\Queue\Stubs\StubQueueProducer; +use Yiisoft\Queue\QueueConsumer; +use Yiisoft\Test\Support\Container\SimpleContainer; final class CompositeQueueProviderTest extends TestCase { public function testCombinesRolesAndPreservesPrecedence(): void { - $firstProducer = new StubQueueProducer('first'); + $firstProducer = $this->createProducer(); + $secondProducer = $this->createProducer(); + $consumer = new QueueConsumer($this->createWorkerFixture(), new SimpleLoop(), new NullLogger()); $provider = new CompositeQueueProvider( new PredefinedQueueProvider(['queue' => ['producer' => $firstProducer]]), - new PredefinedQueueProvider(['queue' => ['producer' => new StubQueueProducer('second'), 'consumer' => new StubQueueConsumer()]]), + new PredefinedQueueProvider(['queue' => ['producer' => $secondProducer, 'consumer' => $consumer]]), ); self::assertSame($firstProducer, $provider->getProducer('queue')); - self::assertInstanceOf(StubQueueConsumer::class, $provider->getConsumer('queue')); + self::assertSame($consumer, $provider->getConsumer('queue')); self::assertSame(['queue'], $provider->getProducerQueueNames()); self::assertSame(['queue'], $provider->getConsumerQueueNames()); } public function testMissingCapabilityThrows(): void { - $provider = new CompositeQueueProvider(new PredefinedQueueProvider(['queue' => ['producer' => new StubQueueProducer()]])); + $provider = new CompositeQueueProvider(new PredefinedQueueProvider(['queue' => ['producer' => $this->createProducer()]])); $this->expectException(QueueNotFoundException::class); $provider->getConsumer('queue'); } + + private function createProducer(): SyncQueueProducer + { + $container = new SimpleContainer(); + + return new SyncQueueProducer( + new NullLogger(), + new PushMiddlewareConfig(new PushMiddlewareFactory($container)), + $this->createWorkerFixture(), + ); + } + + private function createWorkerFixture(): Worker + { + $container = new SimpleContainer(); + + return new Worker( + new NullLogger(), + new ConsumeMiddlewareDispatcher(new ConsumeMiddlewareFactory($container)), + new FailureMiddlewareDispatcher(new FailureMiddlewareFactory($container), []), + new HandlerResolver([], $container), + ); + } } diff --git a/tests/Unit/Provider/PredefinedQueueProviderTest.php b/tests/Unit/Provider/PredefinedQueueProviderTest.php index bf751209..46ca0d5c 100644 --- a/tests/Unit/Provider/PredefinedQueueProviderTest.php +++ b/tests/Unit/Provider/PredefinedQueueProviderTest.php @@ -5,21 +5,31 @@ namespace Yiisoft\Queue\Tests\Unit\Provider; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Yiisoft\Queue\Cli\SimpleLoop; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactory; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareFactory; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; use Yiisoft\Queue\Provider\InvalidQueueConfigException; use Yiisoft\Queue\Provider\PredefinedQueueProvider; use Yiisoft\Queue\Provider\QueueNotFoundException; -use Yiisoft\Queue\Stubs\StubQueueConsumer; -use Yiisoft\Queue\Stubs\StubQueueProducer; +use Yiisoft\Queue\SyncQueueProducer; +use Yiisoft\Queue\Worker\Worker; +use Yiisoft\Queue\Message\Handler\HandlerResolver; +use Yiisoft\Queue\QueueConsumer; use Yiisoft\Queue\Tests\Unit\Support\StringEnum; +use Yiisoft\Test\Support\Container\SimpleContainer; final class PredefinedQueueProviderTest extends TestCase { public function testProvidesIndependentRoles(): void { - $producer = new StubQueueProducer(); - $consumer = new StubQueueConsumer(); + $producer = $this->createProducer(); + $consumer = new QueueConsumer($this->createWorkerFixture(), new SimpleLoop(), new NullLogger()); $provider = new PredefinedQueueProvider(['queue1' => ['producer' => $producer, 'consumer' => $consumer]]); - self::assertSame($producer, $provider->getProducer('queue1')); self::assertSame($consumer, $provider->getConsumer('queue1')); self::assertSame(['queue1'], $provider->getProducerQueueNames()); @@ -28,7 +38,7 @@ public function testProvidesIndependentRoles(): void public function testCapabilityIsolationAndEnumNames(): void { - $provider = new PredefinedQueueProvider(['red' => ['producer' => new StubQueueProducer()]]); + $provider = new PredefinedQueueProvider(['red' => ['producer' => $this->createProducer()]]); self::assertTrue($provider->hasProducer(StringEnum::RED)); self::assertFalse($provider->hasConsumer(StringEnum::RED)); $this->expectException(QueueNotFoundException::class); @@ -37,12 +47,12 @@ public function testCapabilityIsolationAndEnumNames(): void public function testRejectsFlatAndInvalidRoleMaps(): void { - foreach ([['queue' => new StubQueueProducer()], ['queue' => []], ['queue' => ['unknown' => new StubQueueProducer()]]] as $queues) { + foreach ([['queue' => $this->createProducer()], ['queue' => []], ['queue' => ['unknown' => $this->createProducer()]]] as $queues) { try { new PredefinedQueueProvider($queues); self::fail('Invalid role maps must be rejected.'); - } catch (InvalidQueueConfigException) { - self::addToAssertionCount(1); + } catch (InvalidQueueConfigException $exception) { + self::assertStringContainsString('Queue', $exception->getMessage()); } } } @@ -50,6 +60,29 @@ public function testRejectsFlatAndInvalidRoleMaps(): void public function testRejectsWrongRoleInstance(): void { $this->expectException(InvalidQueueConfigException::class); - new PredefinedQueueProvider(['queue' => ['producer' => new StubQueueConsumer()]]); + new PredefinedQueueProvider(['queue' => ['producer' => new QueueConsumer($this->createWorkerFixture(), new SimpleLoop(), new NullLogger())]]); + } + + private function createProducer(): SyncQueueProducer + { + $container = new SimpleContainer(); + + return new SyncQueueProducer( + new NullLogger(), + new PushMiddlewareConfig(new PushMiddlewareFactory($container)), + $this->createWorkerFixture(), + ); + } + + private function createWorkerFixture(): Worker + { + $container = new SimpleContainer(); + + return new Worker( + new NullLogger(), + new ConsumeMiddlewareDispatcher(new ConsumeMiddlewareFactory($container)), + new FailureMiddlewareDispatcher(new FailureMiddlewareFactory($container), []), + new HandlerResolver([], $container), + ); } } diff --git a/tests/Unit/Provider/QueueFactoryProviderTest.php b/tests/Unit/Provider/QueueFactoryProviderTest.php index 51f2a826..11c7704f 100644 --- a/tests/Unit/Provider/QueueFactoryProviderTest.php +++ b/tests/Unit/Provider/QueueFactoryProviderTest.php @@ -8,25 +8,43 @@ use Yiisoft\Queue\Provider\InvalidQueueConfigException; use Yiisoft\Queue\Provider\QueueFactoryProvider; use Yiisoft\Queue\Provider\QueueNotFoundException; +use Yiisoft\Queue\QueueConsumer; use Yiisoft\Queue\Stubs\StubLoop; -use Yiisoft\Queue\Stubs\StubQueueConsumer; -use Yiisoft\Queue\Stubs\StubQueueProducer; +use Yiisoft\Queue\SyncQueueProducer; +use Yiisoft\Queue\Cli\SimpleLoop; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactory; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareFactory; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareConfig; +use Yiisoft\Queue\Middleware\Push\PushMiddlewareFactory; +use Yiisoft\Queue\Worker\Worker; +use Yiisoft\Queue\Message\Handler\HandlerResolver; +use Psr\Log\NullLogger; +use Yiisoft\Test\Support\Container\SimpleContainer; final class QueueFactoryProviderTest extends TestCase { public function testLazilyCreatesRolesIndependently(): void { - $provider = new QueueFactoryProvider(['queue' => ['producer' => StubQueueProducer::class, 'consumer' => StubQueueConsumer::class]]); - self::assertInstanceOf(StubQueueProducer::class, $provider->getProducer('queue')); - self::assertSame($provider->getProducer('queue'), $provider->getProducer('queue')); - self::assertInstanceOf(StubQueueConsumer::class, $provider->getConsumer('queue')); + $producer = $this->createProducer(); + $consumer = new QueueConsumer($this->createWorkerFixture(), new SimpleLoop(), new NullLogger()); + $provider = new QueueFactoryProvider(['queue' => ['producer' => $producer, 'consumer' => $consumer]]); + $resolvedProducer = $provider->getProducer('queue'); + self::assertInstanceOf(SyncQueueProducer::class, $resolvedProducer); + self::assertSame($producer->getQueueName(), $resolvedProducer->getQueueName()); + self::assertSame($resolvedProducer, $provider->getProducer('queue')); + $resolvedConsumer = $provider->getConsumer('queue'); + self::assertInstanceOf(QueueConsumer::class, $resolvedConsumer); + self::assertSame(0, $resolvedConsumer->run()); + self::assertSame($resolvedConsumer, $provider->getConsumer('queue')); self::assertSame(['queue'], $provider->getProducerQueueNames()); self::assertSame(['queue'], $provider->getConsumerQueueNames()); } public function testCapabilityIsolation(): void { - $provider = new QueueFactoryProvider(['producer-only' => ['producer' => StubQueueProducer::class]]); + $provider = new QueueFactoryProvider(['producer-only' => ['producer' => $this->createProducer()]]); self::assertTrue($provider->hasProducer('producer-only')); self::assertFalse($provider->hasConsumer('producer-only')); $this->expectException(QueueNotFoundException::class); @@ -35,12 +53,12 @@ public function testCapabilityIsolation(): void public function testRejectsFlatEmptyAndUnknownRoleMaps(): void { - foreach ([['queue' => StubQueueProducer::class], ['queue' => []], ['queue' => ['unknown' => StubQueueProducer::class]]] as $definitions) { + foreach ([['queue' => $this->createProducer()], ['queue' => []], ['queue' => ['unknown' => $this->createProducer()]]] as $definitions) { try { new QueueFactoryProvider($definitions); self::fail('Invalid role maps must be rejected.'); - } catch (InvalidQueueConfigException) { - self::addToAssertionCount(1); + } catch (InvalidQueueConfigException $exception) { + self::assertStringContainsString('Queue', $exception->getMessage()); } } } @@ -52,9 +70,32 @@ public function testRejectsWrongRoleOnResolutionAndCachesFailure(): void try { $provider->getProducer('queue'); self::fail('Wrong role must be rejected.'); - } catch (InvalidQueueConfigException) { - self::addToAssertionCount(1); + } catch (InvalidQueueConfigException $exception) { + self::assertStringContainsString('Queue', $exception->getMessage()); } } } + + private function createProducer(): SyncQueueProducer + { + $container = new SimpleContainer(); + + return new SyncQueueProducer( + new NullLogger(), + new PushMiddlewareConfig(new PushMiddlewareFactory($container)), + $this->createWorkerFixture(), + ); + } + + private function createWorkerFixture(): Worker + { + $container = new SimpleContainer(); + + return new Worker( + new NullLogger(), + new ConsumeMiddlewareDispatcher(new ConsumeMiddlewareFactory($container)), + new FailureMiddlewareDispatcher(new FailureMiddlewareFactory($container), []), + new HandlerResolver([], $container), + ); + } } diff --git a/tests/Unit/Provider/QueueNotFoundExceptionTest.php b/tests/Unit/Provider/QueueNotFoundExceptionTest.php index 6205fd2a..3b4705a1 100644 --- a/tests/Unit/Provider/QueueNotFoundExceptionTest.php +++ b/tests/Unit/Provider/QueueNotFoundExceptionTest.php @@ -5,12 +5,13 @@ namespace Yiisoft\Queue\Tests\Unit\Provider; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; use Yiisoft\Queue\Provider\QueueNotFoundException; -use Yiisoft\Queue\Tests\TestCase; use Yiisoft\Queue\Tests\Unit\Support\StringEnum; final class QueueNotFoundExceptionTest extends TestCase { + /** @return iterable */ public static function dataBase(): iterable { yield 'string' => ['queue1', 'queue1']; @@ -18,7 +19,7 @@ public static function dataBase(): iterable } #[DataProvider('dataBase')] - public function testBase(string $expectedName, mixed $name): void + public function testBase(string $expectedName, string|StringEnum $name): void { $exception = new QueueNotFoundException($name); diff --git a/tests/Unit/Provider/QueueProducerStatusProviderTest.php b/tests/Unit/Provider/QueueProducerStatusProviderTest.php new file mode 100644 index 00000000..5a30c920 --- /dev/null +++ b/tests/Unit/Provider/QueueProducerStatusProviderTest.php @@ -0,0 +1,84 @@ +createMock(QueueProducerStatusInterface::class); + $status->expects(self::once())->method('status')->with('id')->willReturn(MessageStatus::DONE); + $adapter = $this->createMock(AdapterInterface::class); + $producer = new AsyncQueueProducer(new NullLogger(), $this->createPushMiddlewareConfig(), $adapter, status: $status); + $provider = new QueueProducerStatusProvider(new PredefinedQueueProvider(['red' => ['producer' => $producer]])); + + self::assertTrue($provider->hasStatus(StringEnum::RED)); + self::assertSame(['red'], $provider->getStatusQueueNames()); + self::assertSame($status, $provider->getStatus(StringEnum::RED)); + self::assertSame(MessageStatus::DONE, $provider->getStatus('red')->status('id')); + } + + public function testMissingQueueIsDeterministic(): void + { + $provider = new QueueProducerStatusProvider(new PredefinedQueueProvider([])); + self::assertFalse($provider->hasStatus('missing')); + self::expectException(QueueNotFoundException::class); + $provider->getStatus('missing'); + } + + public function testSupportsSyncProducerStatus(): void + { + $producer = $this->createSyncProducer(); + $provider = new QueueProducerStatusProvider(new PredefinedQueueProvider(['sync' => ['producer' => $producer]])); + self::assertSame(MessageStatus::NOT_FOUND, $provider->getStatus('sync')->status('id')); + } + + private function createPushMiddlewareConfig(): PushMiddlewareConfig + { + $container = new SimpleContainer(); + + return new PushMiddlewareConfig(new PushMiddlewareFactory($container)); + } + + private function createSyncProducer(): SyncQueueProducer + { + $container = new SimpleContainer(); + + return new SyncQueueProducer(new NullLogger(), $this->createPushMiddlewareConfig(), $this->createConcreteWorker()); + } + + private function createConcreteWorker(): Worker + { + $container = new SimpleContainer(); + + return new Worker( + new NullLogger(), + new ConsumeMiddlewareDispatcher(new ConsumeMiddlewareFactory($container)), + new FailureMiddlewareDispatcher(new FailureMiddlewareFactory($container), []), + new HandlerResolver([], $container), + ); + } +} diff --git a/tests/Unit/QueueProducerTest.php b/tests/Unit/QueueProducerTest.php new file mode 100644 index 00000000..56acc824 --- /dev/null +++ b/tests/Unit/QueueProducerTest.php @@ -0,0 +1,47 @@ +createMock(AdapterInterface::class); + $adapter->expects(self::once()) + ->method('status') + ->with('message-id') + ->willReturn(MessageStatus::DONE); + + $producer = new AsyncQueueProducer( + new NullLogger(), + $this->getPushMiddlewareConfig(), + $adapter, + ); + + self::assertSame(MessageStatus::DONE, $producer->getStatus()->status('message-id')); + } + + public function testInjectedStatusIsReturned(): void + { + $adapter = $this->createMock(AdapterInterface::class); + $status = $this->createMock(QueueProducerStatusInterface::class); + + $producer = new AsyncQueueProducer( + new NullLogger(), + $this->getPushMiddlewareConfig(), + $adapter, + status: $status, + ); + + self::assertSame($status, $producer->getStatus()); + } +} diff --git a/tests/Unit/QueueTest.php b/tests/Unit/QueueTest.php index 4923edca..2f9e2c4f 100644 --- a/tests/Unit/QueueTest.php +++ b/tests/Unit/QueueTest.php @@ -6,8 +6,7 @@ use Psr\Log\NullLogger; use Yiisoft\Queue\QueueConsumer; -use Yiisoft\Queue\QueueConsumerInterface; -use Yiisoft\Queue\QueueProducerInterface; +use Yiisoft\Queue\SyncQueueProducer; use Yiisoft\Queue\Message\GenericMessage; use Yiisoft\Queue\Message\IdEnvelope; use Yiisoft\Queue\MessageStatus; @@ -26,8 +25,7 @@ final class QueueTest extends TestCase public function testProducerContract(): void { $queue = $this->createQueue(); - self::assertInstanceOf(QueueProducerInterface::class, $queue); - self::assertFalse(method_exists(QueueProducerInterface::class, 'run')); + self::assertInstanceOf(SyncQueueProducer::class, $queue); } public function testPushAndStatus(): void @@ -38,7 +36,7 @@ public function testPushAndStatus(): void self::assertSame(1, count($adapter->getMessagesList())); /** @var int|string $id */ $id = $envelope->getMeta()[IdEnvelope::META_ID]; - self::assertSame(MessageStatus::WAITING, $queue->status($id)); + self::assertSame(MessageStatus::WAITING, $queue->getStatus()->status($id)); } public function testSynchronousProducerProcessesMessage(): void @@ -46,7 +44,7 @@ public function testSynchronousProducerProcessesMessage(): void $queue = $this->createQueue(); $queue->push(new GenericMessage('simple', null)); self::assertSame(1, $this->executionTimes); - self::assertSame(MessageStatus::NOT_FOUND, $queue->status('1')); + self::assertSame(MessageStatus::NOT_FOUND, $queue->getStatus()->status('1')); } public function testConsumerContractAndRun(): void @@ -55,7 +53,7 @@ public function testConsumerContractAndRun(): void $producer = $this->createQueue($adapter); $producer->push(new GenericMessage('simple', null)); $consumer = new QueueConsumer($this->getWorker(), $this->getLoop(), new NullLogger(), $adapter); - self::assertInstanceOf(QueueConsumerInterface::class, $consumer); + self::assertInstanceOf(QueueConsumer::class, $consumer); self::assertSame(1, $consumer->run()); self::assertSame(1, $this->executionTimes); } diff --git a/tests/Unit/Stubs/StubQueueTest.php b/tests/Unit/Stubs/StubQueueTest.php deleted file mode 100644 index 9200429d..00000000 --- a/tests/Unit/Stubs/StubQueueTest.php +++ /dev/null @@ -1,22 +0,0 @@ -assertSame($message, $queue->push($message)); - $this->assertSame(MessageStatus::DONE, $queue->status('test')); - } -} diff --git a/tests/Unit/Stubs/StubWorkerTest.php b/tests/Unit/Stubs/StubWorkerTest.php deleted file mode 100644 index e10b6cd1..00000000 --- a/tests/Unit/Stubs/StubWorkerTest.php +++ /dev/null @@ -1,26 +0,0 @@ -process($sourceMessage, 'test-queue'); - - $this->assertSame($sourceMessage, $message); - $this->assertSame('test', $message->getType()); - $this->assertSame(42, $message->getPayload()); - $this->assertSame([], $message->getMeta()); - } -} diff --git a/tests/Unit/SyncQueueProducerTest.php b/tests/Unit/SyncQueueProducerTest.php new file mode 100644 index 00000000..814114ee --- /dev/null +++ b/tests/Unit/SyncQueueProducerTest.php @@ -0,0 +1,51 @@ +getPushMiddlewareConfig(), + $this->createConcreteWorker(), + ); + + self::assertSame(MessageStatus::NOT_FOUND, $producer->getStatus()->status('message-id')); + } + + public function testInjectedStatusIsReturned(): void + { + $status = $this->createMock(QueueProducerStatusInterface::class); + + $producer = new SyncQueueProducer( + new NullLogger(), + $this->getPushMiddlewareConfig(), + $this->createConcreteWorker(), + status: $status, + ); + + self::assertSame($status, $producer->getStatus()); + } + + private function createConcreteWorker(): Worker + { + return new Worker( + new NullLogger(), + $this->getConsumeMiddlewareDispatcher(), + $this->getFailureMiddlewareDispatcher(), + new HandlerResolver($this->getMessageHandlers(), $this->getContainer()), + ); + } +} diff --git a/tests/Unit/WorkerTest.php b/tests/Unit/WorkerTest.php index 8d5eae30..b6bb4a60 100644 --- a/tests/Unit/WorkerTest.php +++ b/tests/Unit/WorkerTest.php @@ -7,12 +7,14 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use RuntimeException; +use Throwable; use Yiisoft\Test\Support\Container\SimpleContainer; use Yiisoft\Test\Support\Log\SimpleLogger; use Yiisoft\Queue\Exception\MessageFailureException; use Yiisoft\Queue\Message\Handler\HandlerNotFoundException; use Yiisoft\Queue\Message\Handler\HandlerResolver; use Yiisoft\Queue\Message\GenericMessage; +use Yiisoft\Queue\Message\IdEnvelope; use Yiisoft\Queue\Message\MessageInterface; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactoryInterface; @@ -21,6 +23,10 @@ use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareDispatcher; use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareFactoryInterface; use Yiisoft\Queue\Middleware\FailureHandling\FailureMiddlewareInterface; +use Yiisoft\Queue\Middleware\Worker\WorkerHandlerInterface; +use Yiisoft\Queue\Middleware\Worker\WorkerMiddlewareDispatcher; +use Yiisoft\Queue\Middleware\Worker\WorkerMiddlewareFactory; +use Yiisoft\Queue\Middleware\Worker\WorkerRequest; use Yiisoft\Queue\Tests\App\FakeHandler; use Yiisoft\Queue\Tests\TestCase; use Yiisoft\Queue\Worker\Worker; @@ -49,6 +55,18 @@ public function testMessageHandled(): void $this->assertStringContainsString('Processing message without ID.', $messages[0]['message']); } + public function testMessageWithIdLogsId(): void + { + $message = new IdEnvelope(new GenericMessage('simple', null), 42); + $logger = new SimpleLogger(); + $handlerResolver = $this->createHandlerResolver($message, static function (): void {}); + + $worker = $this->createWorkerByParams($handlerResolver, $logger); + $worker->process($message, 'test-queue'); + + self::assertStringContainsString('Processing message #42.', $logger->getMessages()[0]['message']); + } + public function testMessageFailWithDefinitionHandlerException(): void { $message = new GenericMessage('simple', ['test-data']); @@ -109,6 +127,33 @@ public function testMessageFailureIsHandledSuccessfully(): void self::assertSame($finalMessage, $result); } + public function testWorkerMiddlewareRunsBeforeHandlerResolution(): void + { + $message = new GenericMessage('missing', null); + $seen = false; + $workerMiddleware = new WorkerMiddlewareDispatcher( + new WorkerMiddlewareFactory(new SimpleContainer()), + [static function (WorkerRequest $request, WorkerHandlerInterface $handler) use (&$seen): WorkerRequest { + $seen = true; + return $handler->handleWorker($request); + }], + ); + $worker = $this->createWorkerByParams( + new HandlerResolver([], new SimpleContainer()), + new NullLogger(), + null, + null, + $workerMiddleware, + ); + + $this->expectException(Throwable::class); + try { + $worker->process($message, 'queue'); + } finally { + self::assertTrue($seen); + } + } + public function testUnresolvableHandlerIsHandledByFailurePipeline(): void { $message = new GenericMessage('unsupported', null); @@ -153,17 +198,23 @@ private function createWorkerByParams( ?LoggerInterface $logger = null, ?ConsumeMiddlewareDispatcher $consumeMiddlewareDispatcher = null, ?FailureMiddlewareDispatcher $failureMiddlewareDispatcher = null, + ?WorkerMiddlewareDispatcher $workerMiddlewareDispatcher = null, ): Worker { /** @var ConsumeMiddlewareFactoryInterface&MockObject $consumeMiddlewareFactory */ $consumeMiddlewareFactory = $this->createMock(ConsumeMiddlewareFactoryInterface::class); /** @var FailureMiddlewareFactoryInterface&MockObject $failureMiddlewareFactory */ $failureMiddlewareFactory = $this->createMock(FailureMiddlewareFactoryInterface::class); + $workerMiddlewareDispatcher ??= new WorkerMiddlewareDispatcher( + new WorkerMiddlewareFactory(new SimpleContainer()), + ); + return new Worker( $logger ?? new NullLogger(), $consumeMiddlewareDispatcher ?? new ConsumeMiddlewareDispatcher($consumeMiddlewareFactory), $failureMiddlewareDispatcher ?? new FailureMiddlewareDispatcher($failureMiddlewareFactory, []), $handlerResolver, + $workerMiddlewareDispatcher, ); } }