├── .docker ├── php.ini ├── Dockerfile └── Makefile ├── docs ├── php-simple-queue-941x320.gif └── guide │ ├── README.md │ ├── install.md │ ├── configuration.md │ ├── cookbook.md │ ├── example.md │ ├── transport.md │ ├── producer.md │ └── consuming.md ├── .gitignore ├── src ├── ConfigException.php ├── QueueException.php ├── Transport │ ├── TransportException.php │ ├── TransportInterface.php │ ├── DoctrineDbalTableCreator.php │ └── DoctrineDbalTransport.php ├── Serializer │ ├── SerializerInterface.php │ ├── BaseSerializer.php │ └── SymfonySerializer.php ├── Job.php ├── Context.php ├── Priority.php ├── Status.php ├── Producer.php ├── MessageHydrator.php ├── Message.php ├── Config.php └── Consumer.php ├── .scrutinizer.yml ├── .travis.yml ├── example ├── produce.php ├── consume.php └── advanced-consume.php ├── phpunit.xml ├── tests ├── JobTest.php ├── ContextTest.php ├── Helper │ ├── MockSchemaManager.php │ ├── DBALDriverResult.php │ └── MockConnection.php ├── Serializer │ └── SerializerTest.php ├── MessageStatusTest.php ├── MessagePriorityTest.php ├── Transport │ ├── QueueTableCreatorTest.php │ └── DoctrineDbalTransportTest.php ├── ConsumerTest.php ├── MessageTest.php ├── MessageHydratorTest.php ├── ConfigTest.php └── ProducerTest.php ├── LICENSE ├── .php_cs.php ├── composer.json ├── CHANGELOG.md └── README.md /.docker/php.ini: -------------------------------------------------------------------------------- 1 | [PHP] 2 | xdebug.mode=coverage -------------------------------------------------------------------------------- /docs/php-simple-queue-941x320.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nepster-web/php-simple-queue/HEAD/docs/php-simple-queue-941x320.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /composer.lock 3 | /phpcs.xml 4 | /vendor/ 5 | /.phpunit.result.cache 6 | /.php_cs.cache 7 | /docs/tmp/ 8 | /.coverage.clover -------------------------------------------------------------------------------- /src/ConfigException.php: -------------------------------------------------------------------------------- 1 | 21 | 22 | [Go back](https://github.com/nepster-web/php-simple-queue) -------------------------------------------------------------------------------- /example/produce.php: -------------------------------------------------------------------------------- 1 | 'pdo_sqlite', 9 | 'path' => '/db/queue.db', 10 | ]); 11 | 12 | $transport = new \Simple\Queue\Transport\DoctrineDbalTransport($connection); 13 | 14 | $producer = new \Simple\Queue\Producer($transport, null); 15 | 16 | 17 | echo 'Start send to queue' . PHP_EOL; 18 | 19 | while (true) { 20 | 21 | $message = $producer->createMessage('my_queue', ['id' => uniqid('', true)]); 22 | 23 | $producer->send($message); 24 | 25 | echo sprintf('Sent message: %s ', $message->getBody()); 26 | 27 | echo PHP_EOL; 28 | 29 | sleep(1); 30 | 31 | } 32 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ./tests 22 | 23 | 24 | 25 | 26 | 27 | ./src 28 | 29 | 30 | -------------------------------------------------------------------------------- /example/consume.php: -------------------------------------------------------------------------------- 1 | 'pdo_sqlite', 9 | 'path' => '/db/queue.db', 10 | ]); 11 | 12 | $transport = new \Simple\Queue\Transport\DoctrineDbalTransport($connection); 13 | 14 | $producer = new \Simple\Queue\Producer($transport); 15 | $consumer = new \Simple\Queue\Consumer($transport, $producer); 16 | 17 | // create table for queue messages 18 | $transport->init(); 19 | 20 | 21 | echo 'Start consuming' . PHP_EOL; 22 | 23 | while (true) { 24 | 25 | if ($message = $transport->fetchMessage(['my_queue'])) { 26 | 27 | // Your message handling logic 28 | 29 | $consumer->acknowledge($message); 30 | 31 | echo sprintf('Received message: %s ', $message->getBody()); 32 | 33 | echo PHP_EOL; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /.docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.4.16-fpm-alpine 2 | 3 | ARG LOCAL_ENV 4 | ARG USER_ID 5 | ARG GROUP_ID 6 | 7 | RUN apk update && apk add --no-cache \ 8 | git \ 9 | curl \ 10 | g++ \ 11 | gcc \ 12 | tar \ 13 | zip \ 14 | wget \ 15 | unzip \ 16 | openssh \ 17 | libzip-dev \ 18 | sqlite \ 19 | sqlite-dev \ 20 | shadow 21 | 22 | RUN docker-php-ext-install \ 23 | pdo_sqlite 24 | 25 | RUN apk add --no-cache $PHPIZE_DEPS \ 26 | && pecl install xdebug-3.0.3 \ 27 | && docker-php-ext-enable xdebug; 28 | 29 | RUN mkdir /db && chown -R ${USER_ID}:${GROUP_ID} /db && /usr/bin/sqlite3 /db/queue.db 30 | 31 | RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --version=2.0.11 --filename=composer 32 | 33 | # Set IDs from our local user 34 | RUN usermod -u ${USER_ID} www-data && groupmod -g ${GROUP_ID} www-data || true 35 | USER "${USER_ID}:${GROUP_ID}" 36 | 37 | COPY php.ini /usr/local/etc/php/conf.d/php.ini 38 | 39 | WORKDIR /app -------------------------------------------------------------------------------- /tests/JobTest.php: -------------------------------------------------------------------------------- 1 | generateBaseJob(); 21 | 22 | self::assertEquals('default', $job->queue()); 23 | } 24 | 25 | public function testDefaultAttempts(): void 26 | { 27 | $job = $this->generateBaseJob(); 28 | 29 | self::assertNull($job->attempts()); 30 | } 31 | 32 | /** 33 | * @return Job 34 | */ 35 | private function generateBaseJob(): Job 36 | { 37 | return new class extends Job { 38 | public function handle(Context $context): string 39 | { 40 | return Consumer::STATUS_ACK; 41 | } 42 | }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /example/advanced-consume.php: -------------------------------------------------------------------------------- 1 | 'pdo_sqlite', 9 | 'path' => '/db/queue.db', 10 | ]); 11 | 12 | $processor = static function(\Simple\Queue\Message $message, \Simple\Queue\Producer $producer): string { 13 | 14 | // Your message handling logic 15 | var_dump($message->getBody() . PHP_EOL); 16 | 17 | return \Simple\Queue\Consumer::STATUS_ACK; 18 | }; 19 | 20 | $config = \Simple\Queue\Config::getDefault() 21 | ->changeRedeliveryTimeInSeconds(100) 22 | ->changeNumberOfAttemptsBeforeFailure(3) 23 | ->registerProcessor('my_queue', $processor); 24 | 25 | $transport = new \Simple\Queue\Transport\DoctrineDbalTransport($connection); 26 | 27 | $producer = new \Simple\Queue\Producer($transport, $config); 28 | $consumer = new \Simple\Queue\Consumer($transport, $producer, $config); 29 | 30 | 31 | echo 'Start consuming' . PHP_EOL; 32 | 33 | $consumer->consume(); 34 | -------------------------------------------------------------------------------- /docs/guide/install.md: -------------------------------------------------------------------------------- 1 | PHP Simple Queue Install 2 | ======================== 3 | 4 | Installation recommendations. 5 | 6 | 7 | ## :book: Guide 8 | 9 | * [Guide](./README.md) 10 | * **[Install](./install.md)** 11 | * [Transport](./transport.md) 12 | * [Configuration](./configuration.md) 13 | * [Producer (Send message)](./producer.md) 14 | * [Consuming](./consuming.md) 15 | * [Example](./example.md) 16 | * [Cookbook](./cookbook.md) 17 | 18 |
19 | 20 | ## :page_facing_up: Install 21 | 22 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/): 23 | 24 | Either run 25 | 26 | ``` 27 | php composer.phar require --prefer-dist nepster-web/php-simple-queue 28 | ``` 29 | 30 | or add 31 | 32 | ``` 33 | "nepster-web/php-simple-queue": "*" 34 | ``` 35 | 36 | 37 | 38 | > TIP: When install this package, specify the exact version. For example: `"nepster-web/php-simple-queue": "1.0.0"` 39 | 40 | If you specify the exact version of this library it is possible to avoid problems with updating and breaking backward compatibility. -------------------------------------------------------------------------------- /src/Serializer/SymfonySerializer.php: -------------------------------------------------------------------------------- 1 | serializer = new Serializer([], [new JsonEncoder()]); 25 | } 26 | 27 | /** 28 | * @inheritDoc 29 | */ 30 | public function serialize($data): string 31 | { 32 | return $this->serializer->encode($data, JsonEncoder::FORMAT); 33 | } 34 | 35 | /** 36 | * @inheritDoc 37 | */ 38 | public function deserialize(string $data) 39 | { 40 | return $this->serializer->decode($data, JsonEncoder::FORMAT); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Anatolyi Razumovskyi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/guide/configuration.md: -------------------------------------------------------------------------------- 1 | PHP Simple Queue Usage basics 2 | ============================= 3 | 4 | Configuration. 5 | 6 | 7 | ## :book: Guide 8 | 9 | * [Guide](./README.md) 10 | * [Install](./install.md) 11 | * [Transport](./transport.md) 12 | * **[Configuration](./configuration.md)** 13 | * [Producer (Send message)](./producer.md) 14 | * [Consuming](./consuming.md) 15 | * [Example](./example.md) 16 | * [Cookbook](./cookbook.md) 17 | 18 |
19 | 20 | ## :page_facing_up: Configuration 21 | 22 | You need to use the same config for producer and consumer. 23 | 24 |
25 | 26 | **Create example config:** 27 | 28 | ```php 29 | $config = \Simple\Queue\Config::getDefault() 30 | ->changeRedeliveryTimeInSeconds(100) 31 | ->changeNumberOfAttemptsBeforeFailure(3) 32 | ->withSerializer(new \Simple\Queue\Serializer\BaseSerializer()) 33 | ->registerJob(MyJob::class, new MyJob()) 34 | ->registerProcessor('my_queue', static function(\Simple\Queue\Message $message, \Simple\Queue\Producer $producer): string { 35 | 36 | // Your message handling logic 37 | 38 | return \Simple\Queue\Consumer::STATUS_ACK; 39 | }); 40 | ``` 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/Transport/TransportInterface.php: -------------------------------------------------------------------------------- 1 | producer = $producer; 31 | $this->data = $data; 32 | $this->message = $message; 33 | } 34 | 35 | /** 36 | * @return Producer 37 | */ 38 | public function getProducer(): Producer 39 | { 40 | return $this->producer; 41 | } 42 | 43 | /** 44 | * @return Message 45 | */ 46 | public function getMessage(): Message 47 | { 48 | return $this->message; 49 | } 50 | 51 | /** 52 | * @return array 53 | */ 54 | public function getData(): array 55 | { 56 | return $this->data; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/ContextTest.php: -------------------------------------------------------------------------------- 1 | createMessage('my_queue', ''); 35 | 36 | $context = new Context($producer, $message, []); 37 | 38 | self::assertEquals($producer, $context->getProducer()); 39 | self::assertEquals($message, $context->getMessage()); 40 | self::assertEquals([], $context->getData()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.docker/Makefile: -------------------------------------------------------------------------------- 1 | PROJECT_NAME = php-simple-queue 2 | 3 | USER_ID = $(shell id -u) 4 | GROUP_ID=$(shell id -g) 5 | APP_DIR="${PWD}/.." 6 | 7 | app_run := docker exec -it --user="${USER_ID}" $(PROJECT_NAME) 8 | 9 | .PHONY : help build start stop restart composer php test test-coverage 10 | 11 | .DEFAULT_GOAL := help 12 | 13 | help: ## show this help 14 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 15 | 16 | build: ## build application 17 | docker build --build-arg USER_ID=${USER_ID} --build-arg GROUP_ID=${GROUP_ID} --no-cache --tag ${PROJECT_NAME} . 18 | 19 | start: ## start application (in background) 20 | docker run -d --name=${PROJECT_NAME} -v ${APP_DIR}:/app ${PROJECT_NAME} 21 | make composer cmd=install 22 | 23 | stop: ## stop all containers 24 | docker stop ${PROJECT_NAME} && docker rm ${PROJECT_NAME} 25 | 26 | restart: ## restart all containers 27 | make stop || true 28 | make start 29 | 30 | composer: ## run composer 31 | ifneq ($(cmd),) 32 | $(app_run) sh -c "composer $(cmd)" 33 | else 34 | $(app_run) sh -c "composer update" 35 | endif 36 | 37 | php: ## run php 38 | ifneq ($(cmd),) 39 | $(app_run) sh -c "php $(cmd)" 40 | else 41 | $(app_run) sh -c "php" 42 | endif 43 | -------------------------------------------------------------------------------- /tests/Helper/MockSchemaManager.php: -------------------------------------------------------------------------------- 1 | in(['src', 'tests']); 5 | 6 | $config = new PhpCsFixer\Config(); 7 | return $config 8 | ->setRules([ 9 | '@PSR2' => true, 10 | 'no_empty_phpdoc' => true, 11 | 'single_blank_line_before_namespace' => true, 12 | 'array_syntax' => ['syntax' => 'short'], 13 | 'ordered_imports' => ['sortAlgorithm' => 'length'], 14 | 'no_spaces_after_function_name' => true, 15 | 'no_whitespace_in_blank_line' => true, 16 | 'no_whitespace_before_comma_in_array' => true, 17 | 'no_useless_return' => true, 18 | 'no_useless_else' => true, 19 | 'no_unused_imports' => true, 20 | 'standardize_not_equals' => true, 21 | 'declare_strict_types' => true, 22 | 'is_null' => true, 23 | 'yoda_style' => false, 24 | 'no_empty_statement' => true, 25 | 'void_return' => true, 26 | 'list_syntax' => ['syntax' => 'short'], 27 | 'class_attributes_separation' => [ 28 | 'elements' => [ 29 | 'const', 30 | 'method', 31 | 'property', 32 | ] 33 | ], 34 | 'blank_line_before_statement' => [ 35 | 'statements' => [ 36 | 'return', 37 | ] 38 | ], 39 | ]) 40 | ->setRiskyAllowed(true) 41 | ->setFinder($finder); 42 | -------------------------------------------------------------------------------- /tests/Helper/DBALDriverResult.php: -------------------------------------------------------------------------------- 1 | source = $source; 23 | } 24 | 25 | public function fetchNumeric(): bool 26 | { 27 | return false; 28 | } 29 | 30 | public function fetchAssociative(): array 31 | { 32 | return $this->source['fetchAssociative'] ?? []; 33 | } 34 | 35 | public function fetchOne(): array 36 | { 37 | return []; 38 | } 39 | 40 | public function fetchAllNumeric(): array 41 | { 42 | return []; 43 | } 44 | 45 | public function fetchAllAssociative(): array 46 | { 47 | return []; 48 | } 49 | 50 | public function fetchFirstColumn(): array 51 | { 52 | return []; 53 | } 54 | 55 | public function rowCount(): int 56 | { 57 | return 0; 58 | } 59 | 60 | public function columnCount(): int 61 | { 62 | return 0; 63 | } 64 | 65 | public function free(): void 66 | { 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /docs/guide/cookbook.md: -------------------------------------------------------------------------------- 1 | PHP Simple Queue Cookbook 2 | ========================= 3 | 4 | Tips, recommendations and best practices for use this library. 5 | 6 | 7 | ## :book: Guide 8 | 9 | * [Guide](./README.md) 10 | * [Install](./install.md) 11 | * [Transport](./transport.md) 12 | * [Configuration](./configuration.md) 13 | * [Producer (Send message)](./producer.md) 14 | * [Consuming](./consuming.md) 15 | * [Example](./example.md) 16 | * **[Cookbook](./cookbook.md)** 17 | 18 |
19 | 20 | 21 | ## :page_facing_up: Cookbook 22 | 23 | - If you are using docker, run consumer in an individual container. This will allow you to get away from blocking handling and speed up the application. 24 | 25 | - You can work with serialized objects, but it's better to avoid it. 26 | - First you load the queue with big data. 27 | - Secondly if you change the objects of your application, there is a risk of getting errors when processing messages (which were in the queue). 28 | - *However, this is a recommendation, not a hard and fast prescription, use common sense.* 29 | 30 | - If you are using jobs we recommend using job aliases instead of class namespace. Because in the case of a refactor class or namespace may change messages from the queue continue to be processed. 31 | 32 | - If you are using basic example from [consume.php](../../example/consume.php) you need to watch closely behind leaks in php process. [PHP is meant to die](https://software-gunslinger.tumblr.com/post/47131406821/php-is-meant-to-die). -------------------------------------------------------------------------------- /tests/Serializer/SerializerTest.php: -------------------------------------------------------------------------------- 1 | serialize(['my_data']); 24 | $deserialize = $baseSerializer->deserialize($serialize); 25 | 26 | self::assertEquals(serialize(['my_data']), $serialize); 27 | self::assertEquals(['my_data'], $deserialize); 28 | } 29 | 30 | public function testSymfonySerializer(): void 31 | { 32 | $serializer = new Serializer( 33 | [ 34 | ], 35 | [ 36 | new JsonEncoder(), 37 | ] 38 | ); 39 | 40 | $symfonySerializer = new SymfonySerializer(); 41 | 42 | $serialize = $symfonySerializer->serialize(['my_data']); 43 | $deserialize = $symfonySerializer->deserialize($serialize); 44 | 45 | self::assertEquals($serializer->serialize(['my_data'], JsonEncoder::FORMAT), $serialize); 46 | self::assertEquals(['my_data'], $deserialize); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Priority.php: -------------------------------------------------------------------------------- 1 | priority = $value; 40 | } 41 | 42 | /** 43 | * @return string 44 | */ 45 | public function __toString(): string 46 | { 47 | return (string)$this->priority; 48 | } 49 | 50 | /** 51 | * @return int 52 | */ 53 | public function getValue(): int 54 | { 55 | return $this->priority; 56 | } 57 | 58 | /** 59 | * @return array 60 | */ 61 | public static function getPriorities(): array 62 | { 63 | return [ 64 | self::VERY_LOW, 65 | self::LOW, 66 | self::DEFAULT, 67 | self::HIGH, 68 | self::VERY_HIGH, 69 | ]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /docs/guide/example.md: -------------------------------------------------------------------------------- 1 | PHP Simple Queue Example 2 | ======================== 3 | 4 | Examples and work demonstrating. 5 | 6 | 7 | ## :book: Guide 8 | 9 | * [Guide](./README.md) 10 | * [Install](./install.md) 11 | * [Transport](./transport.md) 12 | * [Configuration](./configuration.md) 13 | * [Producer (Send message)](./producer.md) 14 | * [Consuming](./consuming.md) 15 | * **[Example](./example.md)** 16 | * [Cookbook](./cookbook.md) 17 | 18 |
19 | 20 | ## :page_facing_up: Example 21 | 22 | Example of sending messages to a queue: [produce.php](../../example/produce.php) 23 | 24 | Example of processing messages from a queue: [consume.php](../../example/consume.php) 25 | 26 | Advanced example of processing messages from a queue: [advanced-consume.php](../../example/advanced-consume.php) 27 | 28 |
29 | 30 | ## Quick run with docker 31 | 32 | `cd .docker` - go to dir. 33 | 34 | 35 | **Run the following commands:** 36 | 37 | - `make build` - build docker container 38 | - `make start` - start docker container 39 | - `make php cmd='./example/consume.php'` - run consume (to read messages from the queue) 40 | - `make php cmd='./example/produce.php'` - run produce (to sent messages to the queue) 41 | 42 | 43 | > Both examples should work in different tabs because they are daemons (while(true){}). 44 | 45 |
46 | 47 | **PHP command access:** 48 | 49 | - `make php cmd='{you_command}'`: 50 | - Example: `make php cmd='-v'`: 51 | 52 |
53 | 54 | **Composer command access:** 55 | - `make composer cmd='{you_command}'`: 56 | - Example: - `make composer cmd='update'`: -------------------------------------------------------------------------------- /tests/MessageStatusTest.php: -------------------------------------------------------------------------------- 1 | getValue()); 22 | } 23 | 24 | public function testProcessStatus(): void 25 | { 26 | $status = new Status(Status::IN_PROCESS); 27 | 28 | self::assertEquals(Status::IN_PROCESS, $status->getValue()); 29 | } 30 | 31 | public function testRedeliveredStatus(): void 32 | { 33 | $status = new Status(Status::REDELIVERED); 34 | 35 | self::assertEquals(Status::REDELIVERED, $status->getValue()); 36 | } 37 | 38 | public function testErrorStatus(): void 39 | { 40 | $status = new Status(Status::FAILURE); 41 | 42 | self::assertEquals(Status::FAILURE, $status->getValue()); 43 | } 44 | 45 | public function testAnotherStatus(): void 46 | { 47 | $this->expectException(InvalidArgumentException::class); 48 | $this->expectExceptionMessage(sprintf('"%s" is not a valid message status.', 'my_status')); 49 | 50 | new Status('my_status'); 51 | } 52 | 53 | public function testStatusToString(): void 54 | { 55 | $status = new Status(Status::NEW); 56 | 57 | self::assertEquals(Status::NEW, (string)$status); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nepster-web/php-simple-queue", 3 | "description": "Simple queues implementation in PHP through database.", 4 | "keywords": [ 5 | "php", 6 | "queue", 7 | "simple-queue", 8 | "dbal queue", 9 | "database queue", 10 | "consumer" 11 | ], 12 | "support": { 13 | "docs": "https://github.com/nepster-web/php-simple-queue/blob/main/docs/guide/README.md", 14 | "issues": "https://github.com/nepster-web/php-simple-queue/issues", 15 | "source": "https://github.com/nepster-web/php-simple-queue" 16 | }, 17 | "config": { 18 | "sort-packages": true 19 | }, 20 | "license": "MIT", 21 | "authors": [ 22 | ], 23 | "require": { 24 | "php": "^7.4|^8.0", 25 | "ext-PDO": "*", 26 | "ext-json": "*", 27 | "doctrine/dbal": "^2.0|^3.0", 28 | "laminas/laminas-hydrator": "^4.1", 29 | "ramsey/uuid": "^4.1", 30 | "symfony/serializer": "^5.2" 31 | }, 32 | "require-dev": { 33 | "roave/security-advisories": "dev-master", 34 | "phpunit/phpunit": "^9.5", 35 | "friendsofphp/php-cs-fixer": "^2.18" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "Simple\\Queue\\": "src/" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Simple\\QueueTest\\": "tests/" 45 | } 46 | }, 47 | "scripts": { 48 | "code-style-check": "php vendor/bin/php-cs-fixer fix --verbose --show-progress=dots --dry-run --config=.php_cs.php", 49 | "code-style-fix": "php vendor/bin/php-cs-fixer fix --diff --config=.php_cs.php", 50 | "test": "php vendor/bin/phpunit --colors=always", 51 | "test-coverage": "php vendor/bin/phpunit --coverage-text --coverage-html ./docs/tmp/" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /docs/guide/transport.md: -------------------------------------------------------------------------------- 1 | PHP Simple Queue Usage basics 2 | ============================= 3 | 4 | Transport - provides methods for management messages in queue. 5 | 6 | 7 | ## :book: Guide 8 | 9 | * [Guide](./README.md) 10 | * [Install](./install.md) 11 | * **[Transport](./transport.md)** 12 | * [Configuration](./configuration.md) 13 | * [Producer (Send message)](./producer.md) 14 | * [Consuming](./consuming.md) 15 | * [Example](./example.md) 16 | * [Cookbook](./cookbook.md) 17 | 18 |
19 | 20 | > Currently only supported Doctrine DBAL. 21 | 22 |
23 | 24 | ## :page_facing_up: Transport 25 | 26 | The transport uses [Doctrine DBAL](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/) library and SQL 27 | like server as a broker. It creates a table there. Pushes and pops messages to\from that table. 28 | 29 |
30 | 31 | **Create connection:** 32 | 33 | You can get a DBAL Connection through the Doctrine\DBAL\DriverManager class. 34 | 35 | ```php 36 | $connection = \Doctrine\DBAL\DriverManager::getConnection([ 37 | 'dbname' => 'my_db', 38 | 'user' => 'root', 39 | 'password' => '*******', 40 | 'host' => 'localhost', 41 | 'port' => '5432', 42 | 'driver' => 'pdo_pgsql', 43 | ]); 44 | ``` 45 | 46 | or 47 | 48 | ```php 49 | $connection = \Doctrine\DBAL\DriverManager::getConnection([ 50 | 'driver' => 'pdo_sqlite', 51 | 'path' => '/db/queue.db' 52 | ]); 53 | ``` 54 | 55 | [See more information.](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html) 56 | 57 |
58 | 59 | **Create transport:** 60 | 61 | ```php 62 | $transport = new \Simple\Queue\Transport\DoctrineDbalTransport($connection); 63 | ``` 64 | 65 | -------------------------------------------------------------------------------- /tests/MessagePriorityTest.php: -------------------------------------------------------------------------------- 1 | getValue()); 22 | } 23 | 24 | public function testVeryLowPriority(): void 25 | { 26 | $priority = new Priority(Priority::VERY_LOW); 27 | 28 | self::assertEquals(Priority::VERY_LOW, $priority->getValue()); 29 | } 30 | 31 | public function testLowPriority(): void 32 | { 33 | $priority = new Priority(Priority::LOW); 34 | 35 | self::assertEquals(Priority::LOW, $priority->getValue()); 36 | } 37 | 38 | public function testHighPriority(): void 39 | { 40 | $priority = new Priority(Priority::HIGH); 41 | 42 | self::assertEquals(Priority::HIGH, $priority->getValue()); 43 | } 44 | 45 | public function testVeryHighPriority(): void 46 | { 47 | $priority = new Priority(Priority::VERY_HIGH); 48 | 49 | self::assertEquals(Priority::VERY_HIGH, $priority->getValue()); 50 | } 51 | 52 | public function testAnotherPriority(): void 53 | { 54 | $this->expectException(InvalidArgumentException::class); 55 | $this->expectExceptionMessage(sprintf('"%s" is not a valid message priority.', 777)); 56 | 57 | new Priority(777); 58 | } 59 | 60 | public function testPriorityToString(): void 61 | { 62 | $priority = new Priority(Priority::VERY_LOW); 63 | 64 | self::assertEquals(Priority::VERY_LOW, (int)(string)$priority); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Status.php: -------------------------------------------------------------------------------- 1 | status = $value; 55 | } 56 | 57 | /** 58 | * @return string 59 | */ 60 | public function __toString(): string 61 | { 62 | return $this->status; 63 | } 64 | 65 | /** 66 | * @return string 67 | */ 68 | public function getValue(): string 69 | { 70 | return $this->status; 71 | } 72 | 73 | /** 74 | * @return array 75 | */ 76 | public static function getStatuses(): array 77 | { 78 | return [ 79 | self::NEW, 80 | self::IN_PROCESS, 81 | self::FAILURE, 82 | self::REDELIVERED, 83 | self::UNDEFINED_HANDLER, 84 | ]; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | PHP Simple Queue Change Log 2 | =========================== 3 | 4 | A changelog of all notable changes made to this library. 5 | 6 | - *ENH*: Enhance or modify 7 | - *FIX*: Bug fix or a small change 8 | 9 |
10 | 11 | 1.0.0 under development 12 | ---------------------- 13 | 14 | 15 | 1.0.0-RC under development 16 | ---------------------- 17 | 18 | 19 | 1.0.0-Beta under development 20 | ---------------------- 21 | 22 | 23 | 1.0.0-Alpha-5 May 9, 2021 24 | --------------------------- 25 | - *FIX*: [#28](https://github.com/nepster-web/php-simple-queue/issues/28) - set format for datetime 26 | 27 | 28 | 1.0.0-Alpha-4 April 7, 2021 29 | --------------------------- 30 | - *ENH*: [#22](https://github.com/nepster-web/php-simple-queue/issues/22) - implementation [Context](./src/Context.php) for jobs and processors 31 | - *ENH*: Improved documentation 32 | 33 | 34 | 1.0.0-Alpha-3 March 31, 2021 35 | --------------------------- 36 | - *ENH*: [composer.json](./composer.json) updating package versions 37 | - *ENH*: Implemented abstraction for ([Transport](./src/Transport/DoctrineDbalTransport.php)) 38 | - *ENH*: Config expanded (job registration and processor registration) 39 | - *ENH*: Refactoring class architecture (tests updating) 40 | - *ENH*: Improved documentation 41 | - *FIX*: Fixed data loss when redelivery message 42 | 43 | 44 | 1.0.0-Alpha-2 March 17, 2021 45 | ---------------------------- 46 | - *ENH*: added work with jobs 47 | - *ENH*: added work with processors 48 | - *ENH*: added serializer fo message body 49 | - *ENH*: added [MessageHydrator](./src/MessageHydrator.php) (for change system properties) 50 | - *ENH*: added base [Config](./src/Config.php) 51 | - *ENH*: expanded consumer work algorithms 52 | - *ENH*: increased test coverage 53 | - *ENH*: improved documentation 54 | - *ENH*: updated Dockerfile in example (strict version for: php, composer, xdebug) 55 | 56 | 57 | 1.0.0-Alpha March 13, 2021 58 | -------------------------- 59 | - *ENH*: Repository configuration (travis, scrutinizer, php cs, etc) 60 | - *ENH*: Add simple example with consume and produce 61 | - *ENH*: Ability to run example with docker 62 | 63 | 64 | Release February 15, 2021 65 | ------------------------- 66 | - Create guide 67 | - Create example 68 | - Create tests 69 | - Initial release -------------------------------------------------------------------------------- /src/Transport/DoctrineDbalTableCreator.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 30 | } 31 | 32 | /** 33 | * @param string $tableName 34 | */ 35 | public static function changeTableName(string $tableName): void 36 | { 37 | self::$tableName = $tableName; 38 | } 39 | 40 | /** 41 | * @return string 42 | */ 43 | public static function getTableName(): string 44 | { 45 | return self::$tableName; 46 | } 47 | 48 | /** 49 | * Creating a queue table 50 | * 51 | * @throws \Doctrine\DBAL\Exception 52 | * @throws \Doctrine\DBAL\Schema\SchemaException 53 | */ 54 | public function createDataBaseTable(): void 55 | { 56 | $schemaManager = $this->connection->getSchemaManager(); 57 | 58 | $tableExists = $schemaManager ? $schemaManager->tablesExist([self::getTableName()]) : false; 59 | 60 | if ($schemaManager === null || $tableExists === true) { 61 | return; 62 | } 63 | 64 | $table = new Table(self::getTableName()); 65 | 66 | $table->addColumn('id', Types::GUID, ['length' => 16, 'fixed' => true]); 67 | $table->addColumn('status', Types::STRING); 68 | $table->addColumn('attempts', Types::SMALLINT); 69 | $table->addColumn('queue', Types::STRING); 70 | $table->addColumn('event', Types::STRING, ['notnull' => false]); 71 | $table->addColumn('is_job', Types::BOOLEAN, ['default' => false]); 72 | $table->addColumn('body', Types::TEXT, ['notnull' => false]); 73 | $table->addColumn('priority', Types::SMALLINT, ['notnull' => false]); 74 | $table->addColumn('error', Types::TEXT, ['notnull' => false]); 75 | $table->addColumn('redelivered_at', Types::DATETIME_IMMUTABLE, ['notnull' => false]); 76 | $table->addColumn('created_at', Types::DATETIME_IMMUTABLE); 77 | $table->addColumn('exact_time', Types::BIGINT); 78 | 79 | $table->setPrimaryKey(['id']); 80 | $table->addIndex(['priority', 'created_at', 'queue', 'status', 'event', 'id']); 81 | 82 | $schemaManager->createTable($table); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Transport/QueueTableCreatorTest.php: -------------------------------------------------------------------------------- 1 | createDataBaseTable(); 42 | 43 | /** @var Table $table */ 44 | $table = $schemaManager::$data['createTable']; 45 | 46 | $tableColumns = []; 47 | 48 | /** @var Column $column */ 49 | foreach ($table->getColumns() as $name => $column) { 50 | $tableColumns[$name] = $column->getType()->getName(); 51 | } 52 | 53 | $expected = [ 54 | 'id' => 'guid', 55 | 'status' => 'string', 56 | 'attempts' => 'smallint', 57 | 'queue' => 'string', 58 | 'event' => 'string', 59 | 'is_job' => 'boolean', 60 | 'body' => 'text', 61 | 'priority' => 'smallint', 62 | 'error' => 'text', 63 | 'redelivered_at' => 'datetime_immutable', 64 | 'created_at' => 'datetime_immutable', 65 | 'exact_time' => 'bigint', 66 | ]; 67 | 68 | self::assertEquals($expected, $tableColumns); 69 | } 70 | 71 | public function testSimulateTableCreationWithoutTableCrate(): void 72 | { 73 | $data = []; 74 | 75 | $schemaManager = new class extends MockSchemaManager { 76 | public function tablesExist($names): bool 77 | { 78 | self::$data['tablesExist'] = true; 79 | 80 | return true; 81 | } 82 | }; 83 | $connection = new MockConnection($schemaManager); 84 | 85 | $queueTableCreator = new DoctrineDbalTableCreator($connection); 86 | 87 | $queueTableCreator->createDataBaseTable(); 88 | 89 | $tablesExist = $schemaManager::$data['tablesExist']; 90 | 91 | self::assertTrue($tablesExist); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /docs/guide/producer.md: -------------------------------------------------------------------------------- 1 | PHP Simple Queue Usage basics 2 | ============================= 3 | 4 | Message producer object to send messages to a queue. 5 | 6 | 7 | ## :book: Guide 8 | 9 | * [Guide](./README.md) 10 | * [Install](./install.md) 11 | * [Transport](./transport.md) 12 | * [Configuration](./configuration.md) 13 | * **[Producer (Send message)](./producer.md)** 14 | * [Consuming](./consuming.md) 15 | * [Example](./example.md) 16 | * [Cookbook](./cookbook.md) 17 | 18 |
19 | 20 | ## :page_facing_up: Producer 21 | 22 | You need to configure [$transport](./transport.md) and [$config](./configuration.md) to send new messages. 23 | 24 |
25 | 26 | **Send a new message to queue:** 27 | ------------------------------- 28 | 29 | ```php 30 | $producer = new \Simple\Queue\Producer($transport, $config); 31 | 32 | $producer->send($producer->createMessage('my_queue', ['my_data'])); 33 | ``` 34 | 35 | or a custom example (you need to think about serialization): 36 | 37 | ```php 38 | $producer = new \Simple\Queue\Producer($transport, $config); 39 | 40 | $message = (new \Simple\Queue\Message('my_queue', 'my_data')) 41 | ->withEvent('my_event') 42 | ->changePriority(\Simple\Queue\Priority::VERY_HIGH); 43 | 44 | $producer->send($message); 45 | ``` 46 | 47 | You can send a message from anywhere in the application to process it in the background. 48 | 49 |
50 | 51 | **Send a new message to queue through job:** 52 | ------------------------------- 53 | 54 | ```php 55 | $producer = new \Simple\Queue\Producer($transport, $config); 56 | 57 | $producer->dispatch(MyJob::class, ['key' => 'value']); 58 | ``` 59 | 60 |
61 | 62 | > You can send a message to the queue from anywhere in the application where available $producer. 63 | 64 |
65 | 66 | **Message** 67 | ---------------------- 68 | 69 | Description of the base entity [Message](../../src/Message.php). 70 | 71 | ```php 72 | 73 | // create new Message 74 | $message = new \Simple\Queue\Message('my_queue', 'my_data'); 75 | 76 | // public getters 77 | $message->getId(); 78 | $message->getStatus(); 79 | $message->isJob(); 80 | $message->getError(); 81 | $message->getExactTime(); 82 | $message->getCreatedAt(); 83 | $message->getAttempts(); 84 | $message->getQueue(); 85 | $message->getEvent(); 86 | $message->getBody(); 87 | $message->getPriority(); 88 | $message->getRedeliveredAt(); 89 | $message->isRedelivered(); 90 | 91 | // public setters 92 | $message->changeRedeliveredAt($redeliveredAt); 93 | $message->changeQueue($queue); 94 | $message->changePriority($priority); 95 | $message->withEvent($event); 96 | ``` 97 | 98 | Each message has [Status](../../src/Status.php) and [Priority](../../src/Priority.php). 99 | 100 | * **Status**
101 | Used to delimit messages in a queue (system parameter, not available for public modification).
102 | Possible options: NEW; IN_PROCESS; ERROR; REDELIVERED. 103 | 104 | 105 | * **Priority**
106 | Used to sort messages in the consumer.
107 | Possible options: VERY_LOW = -2; LOW = -1; DEFAULT = 0; HIGH = 1; VERY_HIGH = 2. -------------------------------------------------------------------------------- /tests/Helper/MockConnection.php: -------------------------------------------------------------------------------- 1 | abstractSchemaManager = $abstractSchemaManager ?: new MockSchemaManager(); 38 | $this->source = $source; 39 | 40 | $driver = new class extends AbstractSQLiteDriver { 41 | public function connect(array $params): void 42 | { 43 | } 44 | }; 45 | 46 | parent::__construct([], $driver); 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public function insert($table, array $data, array $types = []): int 53 | { 54 | self::$data['insert'] = [ 55 | 'table' => $table, 56 | 'data' => $data, 57 | 'types' => $types, 58 | ]; 59 | 60 | return $this->source['insert'] ?? 0; 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | public function update($table, array $data, array $criteria, array $types = []): int 67 | { 68 | self::$data['update'] = [ 69 | 'table' => $table, 70 | 'data' => $data, 71 | 'criteria' => $criteria, 72 | 'types' => $types, 73 | ]; 74 | 75 | return 0; 76 | } 77 | 78 | /** 79 | * @inheritDoc 80 | */ 81 | public function delete($table, array $criteria, array $types = []): int 82 | { 83 | self::$data['delete'] = [ 84 | 'table' => $table, 85 | 'criteria' => $criteria, 86 | 'types' => $types, 87 | ]; 88 | 89 | return 0; 90 | } 91 | 92 | /** 93 | * @return AbstractSchemaManager|MockSchemaManager 94 | */ 95 | public function getSchemaManager(): AbstractSchemaManager 96 | { 97 | return $this->abstractSchemaManager; 98 | } 99 | 100 | /** 101 | * @inheritDoc 102 | */ 103 | public function executeQuery(string $sql, array $params = [], $types = [], ?QueryCacheProfile $qcp = null): Result 104 | { 105 | $result = new DBALDriverResult($this->source); 106 | 107 | return new Result($result, $this); 108 | } 109 | 110 | /** 111 | * @return QueryBuilder 112 | */ 113 | public function createQueryBuilder(): QueryBuilder 114 | { 115 | return new class($this) extends QueryBuilder { 116 | }; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/ConsumerTest.php: -------------------------------------------------------------------------------- 1 | getId(); 37 | } 38 | }; 39 | 40 | $consumer = new Consumer($transport, new Producer($transport)); 41 | $consumer->acknowledge($message); 42 | 43 | self::assertEquals($id, $transport::$deleteMessageId); 44 | } 45 | 46 | public function testRejectWithRequeue(): void 47 | { 48 | $id = '71a384ad-952d-417f-9dc5-dfdb5b01704d'; 49 | $message = new Message('my_queue', 'my_data'); 50 | 51 | MessageHydrator::changeProperty($message, 'id', $id); 52 | 53 | $transport = new class(new MockConnection()) extends DoctrineDbalTransport { 54 | public static string $deleteMessageId; 55 | 56 | public function deleteMessage(Message $message): void 57 | { 58 | self::$deleteMessageId = $message->getId(); 59 | } 60 | 61 | public static Message $message; 62 | 63 | public function send(Message $message): void 64 | { 65 | self::$message = $message; 66 | } 67 | }; 68 | 69 | $producer = new Producer($transport); 70 | 71 | $consumer = new Consumer($transport, $producer); 72 | $consumer->reject($message, true); 73 | 74 | self::assertEquals($id, $transport::$deleteMessageId); 75 | 76 | self::assertEquals(Status::REDELIVERED, $transport::$message->getStatus()); 77 | self::assertEquals( 78 | (new DateTimeImmutable()) 79 | ->modify(sprintf('+%s seconds', Config::getDefault()->getRedeliveryTimeInSeconds())) 80 | ->format('Y-m-d H:i:s'), 81 | $transport::$message->getRedeliveredAt()->format('Y-m-d H:i:s') 82 | ); 83 | } 84 | 85 | public function testRejectWithoutRequeue(): void 86 | { 87 | $id = '71a384ad-952d-417f-9dc5-dfdb5b01704d'; 88 | $message = new Message('my_queue', 'my_data'); 89 | 90 | MessageHydrator::changeProperty($message, 'id', $id); 91 | 92 | $transport = new class(new MockConnection()) extends DoctrineDbalTransport { 93 | public static string $deleteMessageId; 94 | 95 | public function deleteMessage(Message $message): void 96 | { 97 | self::$deleteMessageId = $message->getId(); 98 | } 99 | }; 100 | 101 | $consumer = new Consumer($transport, new Producer($transport)); 102 | $consumer->reject($message); 103 | 104 | self::assertEquals($id, $transport::$deleteMessageId); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/MessageTest.php: -------------------------------------------------------------------------------- 1 | expectException(QueueException::class); 30 | $this->expectExceptionMessage('The message has no id. It looks like it was not sent to the queue.'); 31 | $message->getId(); 32 | 33 | self::assertNull($message->getEvent()); 34 | self::assertNull($message->getError()); 35 | self::assertNull($message->getRedeliveredAt()); 36 | 37 | self::assertEquals(0, $message->getAttempts()); 38 | self::assertEquals('my_queue', $message->getQueue()); 39 | self::assertEquals($body, $message->getBody()); 40 | self::assertEquals(Status::NEW, $message->getStatus()); 41 | self::assertEquals(Priority::DEFAULT, $message->getPriority()); 42 | self::assertEquals($time, $message->getExactTime()); 43 | self::assertEquals(date('Y-m-d H:i:s', $time), $message->getCreatedAt()->format('Y-m-d H:i:s')); 44 | } 45 | 46 | public function testCreateNewDefaultMessageWithCeil(): void 47 | { 48 | $body = json_encode([], JSON_THROW_ON_ERROR); 49 | 50 | $time = time(); 51 | $message = (new Message('my_queue', $body)) 52 | ->changePriority(Priority::LOW) 53 | ->withEvent('my_event') 54 | ->changeRedeliveredAt(new DateTimeImmutable()); 55 | 56 | self::assertEquals(0, $message->getAttempts()); 57 | self::assertEquals('my_queue', $message->getQueue()); 58 | self::assertEquals('my_event', $message->getEvent()); 59 | self::assertEquals($body, $message->getBody()); 60 | self::assertEquals(Priority::LOW, $message->getPriority()); 61 | 62 | self::assertEquals(date('Y-m-d H:i:s', $time), $message->getCreatedAt()->format('Y-m-d H:i:s')); 63 | } 64 | 65 | public function testChangePriority(): void 66 | { 67 | $message = new Message('my_queue', ''); 68 | $message->changePriority(Priority::HIGH); 69 | 70 | self::assertEquals(Priority::HIGH, $message->getPriority()); 71 | } 72 | 73 | public function testChangeQueue(): void 74 | { 75 | $message = new Message('my_queue', ''); 76 | $message->changeQueue('new_queue'); 77 | 78 | self::assertEquals('new_queue', $message->getQueue()); 79 | } 80 | 81 | public function testSetEvent(): void 82 | { 83 | $message = new Message('my_queue', ''); 84 | $message->withEvent('my_event'); 85 | 86 | self::assertEquals('my_event', $message->getEvent()); 87 | } 88 | 89 | public function testRedeliveredAt(): void 90 | { 91 | $redelivered = new DateTimeImmutable(); 92 | 93 | $message = new Message('my_queue', ''); 94 | $message->changeRedeliveredAt($redelivered); 95 | 96 | self::assertTrue($message->isRedelivered()); 97 | self::assertEquals($redelivered->format('Y-m-d H:i:s'), $message->getRedeliveredAt()->format('Y-m-d H:i:s')); 98 | } 99 | 100 | public function testRedeliveredAtByStatus(): void 101 | { 102 | $message = new Message('my_queue', ''); 103 | 104 | $message = (new MessageHydrator($message)) 105 | ->changeStatus(Status::REDELIVERED) 106 | ->getMessage(); 107 | 108 | self::assertTrue($message->isRedelivered()); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Producer.php: -------------------------------------------------------------------------------- 1 | transport = $transport; 33 | $this->config = $config ?: Config::getDefault(); 34 | } 35 | 36 | /** 37 | * Create new message 38 | * 39 | * @param string $queue 40 | * @param $body 41 | * @return Message 42 | * @throws QueueException 43 | */ 44 | public function createMessage(string $queue, $body): Message 45 | { 46 | if (is_callable($body)) { 47 | throw new QueueException('The closure cannot be serialized.'); 48 | } 49 | 50 | if (is_object($body) && method_exists($body, '__toString')) { 51 | $body = (string)$body; 52 | } 53 | 54 | if (is_object($body) || is_array($body)) { 55 | $body = $this->config->getSerializer()->serialize($body); 56 | } 57 | 58 | return new Message($queue, (string)$body); 59 | } 60 | 61 | /** 62 | * Redelivered a message to the queue 63 | * 64 | * @param Message $message 65 | * @return Message 66 | */ 67 | public function makeRedeliveryMessage(Message $message): Message 68 | { 69 | $newStatus = ($message->getStatus() === Status::NEW || $message->getStatus() === Status::IN_PROCESS) ? 70 | Status::REDELIVERED : 71 | $message->getStatus(); 72 | 73 | $redeliveredTime = (new DateTimeImmutable('now')) 74 | ->modify(sprintf('+%s seconds', $this->config->getRedeliveryTimeInSeconds())); 75 | 76 | if ( 77 | $message->getRedeliveredAt() && 78 | $message->getRedeliveredAt()->getTimestamp() > $redeliveredTime->getTimestamp() 79 | ) { 80 | $redeliveredTime = $message->getRedeliveredAt(); 81 | } 82 | 83 | if ($newStatus === Status::FAILURE) { 84 | $redeliveredTime = null; 85 | } 86 | 87 | $redeliveredMessage = (new Message($message->getQueue(), $message->getBody())) 88 | ->changePriority($message->getPriority()) 89 | ->withEvent($message->getEvent()) 90 | ->changeRedeliveredAt($redeliveredTime); 91 | 92 | return (new MessageHydrator($redeliveredMessage)) 93 | ->changeStatus($newStatus) 94 | ->jobable($message->isJob()) 95 | ->setError($message->getError()) 96 | ->changeAttempts($message->getAttempts() + 1) 97 | ->getMessage(); 98 | } 99 | 100 | /** 101 | * Dispatch a job 102 | * 103 | * @param string $jobName 104 | * @param array $data 105 | * @throws QueueException 106 | */ 107 | public function dispatch(string $jobName, array $data): void 108 | { 109 | $job = $this->config->getJob($jobName); 110 | 111 | $message = $this->createMessage($job->queue(), $data) 112 | ->withEvent($this->config->getJobAlias($jobName)); 113 | 114 | $message = (new MessageHydrator($message))->jobable()->getMessage(); 115 | 116 | $this->send($message); 117 | } 118 | 119 | /** 120 | * Send message to queue 121 | * 122 | * @param Message $message 123 | */ 124 | public function send(Message $message): void 125 | { 126 | $this->transport->send($message); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/MessageHydrator.php: -------------------------------------------------------------------------------- 1 | message = clone $message; 33 | } 34 | 35 | /** 36 | * @param string $status 37 | * @return MessageHydrator 38 | */ 39 | public function changeStatus(string $status): self 40 | { 41 | $this->message = $this->hydrate(['status' => new Status($status)]); 42 | 43 | return $this; 44 | } 45 | 46 | /** 47 | * @param bool $isJob 48 | * @return $this 49 | */ 50 | public function jobable(bool $isJob = true): self 51 | { 52 | $this->message = $this->hydrate(['isJob' => $isJob]); 53 | 54 | return $this; 55 | } 56 | 57 | /** 58 | * @param string|null $error 59 | * @return $this 60 | */ 61 | public function setError(?string $error): self 62 | { 63 | $this->message = $this->hydrate(['error' => $error]); 64 | 65 | return $this; 66 | } 67 | 68 | /** 69 | * @return $this 70 | */ 71 | public function increaseAttempt(): self 72 | { 73 | $this->hydrate(['attempts' => $this->message->getAttempts() + 1]); 74 | 75 | return $this; 76 | } 77 | 78 | /** 79 | * @param int $amount 80 | * @return $this 81 | */ 82 | public function changeAttempts(int $amount): self 83 | { 84 | $this->hydrate(['attempts' => $amount]); 85 | 86 | return $this; 87 | } 88 | 89 | /** 90 | * @return Message 91 | */ 92 | public function getMessage(): Message 93 | { 94 | return $this->message; 95 | } 96 | 97 | /** 98 | * @param Message $message 99 | * @param string $property 100 | * @param $value 101 | * @throws ReflectionException 102 | */ 103 | public static function changeProperty(Message $message, string $property, $value): void 104 | { 105 | $r = new ReflectionProperty($message, $property); 106 | $r->setAccessible(true); 107 | $r->setValue($message, $value); 108 | } 109 | 110 | /** 111 | * Create entity Message from array data 112 | * 113 | * @param array $data 114 | * @return Message 115 | * @throws Exception 116 | */ 117 | public static function createMessage(array $data): Message 118 | { 119 | $strategy = new HydratorStrategy(new ReflectionHydrator(), Message::class); 120 | 121 | /** @var Message $message */ 122 | $message = $strategy->hydrate(array_merge($data, [ 123 | 'queue' => $data['queue'] ?? 'default', 124 | 'event' => $data['event'] ?? null, 125 | 'isJob' => $data['is_job'] ?? false, 126 | 'body' => $data['body'] ?? '', 127 | 'error' => $data['error'] ?? null, 128 | 'attempts' => $data['attempts'] ?? 0, 129 | 'status' => new Status($data['status'] ?? Status::NEW), 130 | 'priority' => new Priority((int)($data['priority'] ?? Priority::DEFAULT)), 131 | 'exactTime' => $data['exact_time'] ?? time(), 132 | 'createdAt' => new DateTimeImmutable($data['created_at'] ?? 'now'), 133 | 'redeliveredAt' => isset($data['redelivered_at']) ? new DateTimeImmutable($data['redelivered_at']) : null, 134 | ])); 135 | 136 | return $message; 137 | } 138 | 139 | /** 140 | * @param array $data 141 | * @return Message 142 | */ 143 | protected function hydrate(array $data): Message 144 | { 145 | /** @var Message $redeliveredMessage */ 146 | $redeliveredMessage = (new ReflectionHydrator())->hydrate($data, $this->message); 147 | 148 | return $redeliveredMessage; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > This package is under development. Api classes of this application can be changed. 2 | 3 |

4 |

PHP Simple Queue

5 |

6 | 7 |

8 | Release 9 | Build 10 | Coverage 11 | Downloads 12 | License 13 |

14 | 15 |

16 | Example of work 17 |

18 | 19 | 20 | Introduction 21 | ------------ 22 | 23 | **PHP Simple Queue** - a library for running tasks asynchronously via queues. 24 | It is production ready, battle-tested a simple messaging solution for PHP. 25 | 26 | It supports queues based on **DB**. 27 | 28 | Requirements 29 | ------------ 30 | 31 | You'll need at least PHP 7.4 (it works best with PHP 8). 32 | 33 | 34 | Installation 35 | ------------ 36 | 37 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/): 38 | 39 | Either run 40 | 41 | ``` 42 | php composer.phar require --prefer-dist nepster-web/php-simple-queue 43 | ``` 44 | 45 | or add 46 | 47 | ``` 48 | "nepster-web/php-simple-queue": "*" 49 | ``` 50 | 51 | 52 | :computer: Basic Usage 53 | ---------------------- 54 | 55 | Create transport ([see more information](./docs/guide/transport.md)): 56 | 57 | ```php 58 | $transport = new \Simple\Queue\Transport\DoctrineDbalTransport($connection); 59 | ``` 60 | 61 | 62 | ### Send a new message to queue (producing) 63 | 64 | ```php 65 | $config = \Simple\Queue\Config::getDefault() 66 | ->registerProcessor('my_queue', static function(\Simple\Queue\Context $context): string { 67 | // Your message handling logic 68 | return \Simple\Queue\Consumer::STATUS_ACK; 69 | }); 70 | 71 | $producer = new \Simple\Queue\Producer($transport, $config); 72 | 73 | $message = $producer->createMessage('my_queue', ['key' => 'value']); 74 | 75 | $producer->send($message); 76 | ``` 77 | 78 | 79 | ### Job dispatching (producing) 80 | 81 | ```php 82 | $config = \Simple\Queue\Config::getDefault() 83 | ->registerJob(MyJob::class, new MyJob()); 84 | 85 | $producer = new \Simple\Queue\Producer($transport, $config); 86 | 87 | $producer->dispatch(MyJob::class, ['key' => 'value']); 88 | ``` 89 | 90 | 91 | ### Processing messages from queue (consuming) 92 | 93 | ```php 94 | $producer = new \Simple\Queue\Producer($transport, $config); 95 | $consumer = new \Simple\Queue\Consumer($transport, $producer, $config); 96 | 97 | $consumer->consume(); 98 | ``` 99 | 100 | For more details see the [example code](./example) and read the [guide](./docs/guide/example.md). 101 | 102 | 103 | ### Testing 104 | 105 | To run the tests locally, in the root directory execute below 106 | 107 | ``` 108 | ./vendor/bin/phpunit 109 | ``` 110 | 111 | or you can run tests in a docker container 112 | 113 | ``` 114 | cd .docker 115 | make build 116 | make start 117 | make composer cmd='test' 118 | ``` 119 | 120 | --------------------------------- 121 | 122 | ## :book: Documentation 123 | 124 | See [the official guide](./docs/guide/README.md). 125 | 126 | 127 | ## :books: Resources 128 | 129 | * [Documentation](./docs/guide/README.md) 130 | * [Example](./example) 131 | * [Issue Tracker](https://github.com/nepster-web/php-simple-queue/issues) 132 | 133 | 134 | ## :newspaper: Changelog 135 | 136 | Detailed changes for each release are documented in the [CHANGELOG.md](./CHANGELOG.md). 137 | 138 | 139 | ## :lock: License 140 | 141 | See the [MIT License](LICENSE) file for license rights and limitations (MIT). -------------------------------------------------------------------------------- /tests/MessageHydratorTest.php: -------------------------------------------------------------------------------- 1 | generateBaseMessage(); 23 | 24 | $newMessage = (new MessageHydrator($message))->getMessage(); 25 | 26 | self::assertNotEquals(spl_object_id($message), spl_object_id($newMessage)); 27 | } 28 | 29 | public function testGetMessage(): void 30 | { 31 | $message = $this->generateBaseMessage(); 32 | 33 | $newMessage = (new MessageHydrator($message))->getMessage(); 34 | 35 | self::assertInstanceOf(Message::class, $newMessage); 36 | } 37 | 38 | public function testChangeStatus(): void 39 | { 40 | $message = $this->generateBaseMessage(); 41 | 42 | $newMessage = (new MessageHydrator($message)) 43 | ->changeStatus(Status::IN_PROCESS) 44 | ->getMessage(); 45 | 46 | self::assertEquals(Status::NEW, $message->getStatus()); 47 | self::assertEquals(Status::IN_PROCESS, $newMessage->getStatus()); 48 | } 49 | 50 | public function testJobable(): void 51 | { 52 | $message = $this->generateBaseMessage(); 53 | 54 | $newMessage = (new MessageHydrator($message)) 55 | ->jobable() 56 | ->getMessage(); 57 | 58 | self::assertFalse($message->isJob()); 59 | self::assertTrue($newMessage->isJob()); 60 | } 61 | 62 | public function testUnJobable(): void 63 | { 64 | $message = $this->generateBaseMessage(); 65 | 66 | $newMessage = (new MessageHydrator($message)) 67 | ->jobable(false) 68 | ->getMessage(); 69 | 70 | self::assertFalse($message->isJob()); 71 | self::assertFalse($newMessage->isJob()); 72 | } 73 | 74 | public function testSetError(): void 75 | { 76 | $message = $this->generateBaseMessage(); 77 | 78 | $newMessage = (new MessageHydrator($message)) 79 | ->setError('myError') 80 | ->getMessage(); 81 | 82 | self::assertNull($message->getError()); 83 | self::assertEquals('myError', $newMessage->getError()); 84 | } 85 | 86 | public function testSetErrorWithNull(): void 87 | { 88 | $message = $this->generateBaseMessage(); 89 | 90 | $newMessage = (new MessageHydrator($message)) 91 | ->setError(null) 92 | ->getMessage(); 93 | 94 | self::assertNull($message->getError()); 95 | self::assertNull($newMessage->getError()); 96 | } 97 | 98 | public function testIncreaseAttempt(): void 99 | { 100 | $message = $this->generateBaseMessage(); 101 | 102 | $newMessage = (new MessageHydrator($message)) 103 | ->increaseAttempt() 104 | ->getMessage(); 105 | 106 | self::assertEquals(0, $message->getAttempts()); 107 | self::assertEquals(1, $newMessage->getAttempts()); 108 | } 109 | 110 | public function testCreateMessage(): void 111 | { 112 | $time = time(); 113 | $date = (new DateTimeImmutable())->format('Y-m-d H:i:s'); 114 | 115 | $message = MessageHydrator::createMessage([ 116 | 'queue' => 'my_queue', 117 | 'event' => 'my_event', 118 | 'is_job' => true, 119 | 'body' => 'my_body', 120 | 'error' => 'my_error', 121 | 'attempts' => 7, 122 | 'status' => Status::NEW, 123 | 'priority' => Priority::DEFAULT, 124 | 'exact_time' => $time, 125 | 'created_at' => $date, 126 | 'redelivered_at' => $date, 127 | ]); 128 | 129 | self::assertEquals('my_queue', $message->getQueue()); 130 | self::assertEquals('my_event', $message->getEvent()); 131 | self::assertTrue($message->isJob()); 132 | self::assertEquals('my_body', $message->getBody()); 133 | self::assertEquals('my_error', $message->getError()); 134 | self::assertEquals(7, $message->getAttempts()); 135 | self::assertEquals(Status::NEW, $message->getStatus()); 136 | self::assertEquals(Priority::DEFAULT, $message->getPriority()); 137 | self::assertEquals($time, $message->getExactTime()); 138 | self::assertEquals($date, $message->getCreatedAt()->format('Y-m-d H:i:s')); 139 | self::assertEquals($date, $message->getRedeliveredAt()->format('Y-m-d H:i:s')); 140 | } 141 | 142 | /** 143 | * @return Message 144 | */ 145 | private function generateBaseMessage(): Message 146 | { 147 | return new Message('default', 'my_data'); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Transport/DoctrineDbalTransport.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 32 | } 33 | 34 | /** 35 | * @inheritDoc 36 | */ 37 | public function init(): void 38 | { 39 | (new DoctrineDbalTableCreator($this->connection))->createDataBaseTable(); 40 | } 41 | 42 | /** 43 | * @inheritDoc 44 | * @throws TransportException 45 | */ 46 | public function fetchMessage(array $queues = []): ?Message 47 | { 48 | $nowTime = time(); 49 | $endAt = microtime(true) + 0.2; // add 200ms 50 | 51 | $select = $this->connection->createQueryBuilder() 52 | ->select('*') 53 | ->from(DoctrineDbalTableCreator::getTableName()) 54 | ->andWhere('status IN (:statuses)') 55 | ->andWhere('redelivered_at IS NULL OR redelivered_at <= :redeliveredAt') 56 | ->andWhere('exact_time <= :nowTime') 57 | ->addOrderBy('priority', 'asc') 58 | ->addOrderBy('created_at', 'asc') 59 | ->setParameter('redeliveredAt', (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'), Types::STRING) 60 | ->setParameter('statuses', [Status::NEW, Status::REDELIVERED], Connection::PARAM_STR_ARRAY) 61 | ->setParameter('nowTime', $nowTime, Types::INTEGER) 62 | ->setMaxResults(1); 63 | 64 | if (count($queues)) { 65 | $select 66 | ->where('queue IN (:queues)') 67 | ->setParameter('queues', $queues, Connection::PARAM_STR_ARRAY); 68 | } 69 | 70 | while (microtime(true) < $endAt) { 71 | try { 72 | $deliveredMessage = $select->execute()->fetchAssociative(); 73 | 74 | if (empty($deliveredMessage)) { 75 | continue; 76 | } 77 | 78 | return MessageHydrator::createMessage($deliveredMessage); 79 | } catch (Throwable $e) { 80 | throw new TransportException(sprintf('Error reading queue in consumer: "%s".', $e->getMessage()), 0, $e); 81 | } 82 | } 83 | 84 | return null; 85 | } 86 | 87 | /** 88 | * @inheritDoc 89 | * @throws TransportException 90 | */ 91 | public function send(Message $message): void 92 | { 93 | $dataMessage = [ 94 | 'id' => Uuid::uuid4()->toString(), 95 | 'status' => $message->getStatus(), 96 | 'created_at' => $message->getCreatedAt()->format('Y-m-d H:i:s'), 97 | 'redelivered_at' => $message->getRedeliveredAt() ? $message->getRedeliveredAt()->format('Y-m-d H:i:s') : null, 98 | 'attempts' => $message->getAttempts(), 99 | 'queue' => $message->getQueue(), 100 | 'event' => $message->getEvent(), 101 | 'is_job' => $message->isJob(), 102 | 'body' => $message->getBody(), 103 | 'priority' => $message->getPriority(), 104 | 'error' => $message->getError(), 105 | 'exact_time' => $message->getExactTime(), 106 | ]; 107 | try { 108 | $rowsAffected = $this->connection->insert(DoctrineDbalTableCreator::getTableName(), $dataMessage, [ 109 | 'id' => Types::GUID, 110 | 'status' => Types::STRING, 111 | 'created_at' => Types::STRING, 112 | 'redelivered_at' => Types::STRING, 113 | 'attempts' => Types::SMALLINT, 114 | 'queue' => Types::STRING, 115 | 'event' => Types::STRING, 116 | 'is_job' => Types::BOOLEAN, 117 | 'body' => Types::TEXT, 118 | 'priority' => Types::SMALLINT, 119 | 'error' => Types::TEXT, 120 | 'exact_time' => Types::BIGINT, 121 | ]); 122 | if ($rowsAffected !== 1) { 123 | throw new TransportException('The message was not enqueued. Dbal did not confirm that the record is inserted.'); 124 | } 125 | } catch (Throwable $e) { 126 | throw new TransportException(sprintf('The transport fails to send the message: %s', $e->getMessage()), 0, $e); 127 | } 128 | } 129 | 130 | /** 131 | * @inheritDoc 132 | * @throws TransportException 133 | */ 134 | public function changeMessageStatus(Message $message, Status $status): void 135 | { 136 | $this->connection->update( 137 | DoctrineDbalTableCreator::getTableName(), 138 | ['status' => (string)$status], 139 | ['id' => $message->getId()] 140 | ); 141 | 142 | MessageHydrator::changeProperty($message, 'status', $status); 143 | } 144 | 145 | /** 146 | * @inheritDoc 147 | * @throws TransportException 148 | */ 149 | public function deleteMessage(Message $message): void 150 | { 151 | $this->connection->delete( 152 | DoctrineDbalTableCreator::getTableName(), 153 | ['id' => $message->getId()], 154 | ['id' => Types::GUID] 155 | ); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Message.php: -------------------------------------------------------------------------------- 1 | status = new Status(Status::NEW); 102 | $this->queue = $queue; 103 | $this->body = $body; 104 | $this->priority = new Priority(Priority::DEFAULT); 105 | $this->attempts = 0; 106 | $this->error = null; 107 | $this->event = null; 108 | $this->isJob = false; 109 | $this->exactTime = time(); 110 | $this->createdAt = new DateTimeImmutable('now'); 111 | $this->redeliveredAt = null; 112 | } 113 | 114 | /** 115 | * @return string 116 | * @throws QueueException 117 | */ 118 | public function getId(): string 119 | { 120 | if ($this->id === null) { 121 | throw new QueueException('The message has no id. It looks like it was not sent to the queue.'); 122 | } 123 | 124 | return $this->id; 125 | } 126 | 127 | /** 128 | * @return string 129 | */ 130 | public function getStatus(): string 131 | { 132 | return (string)$this->status; 133 | } 134 | 135 | /** 136 | * @return string|null 137 | */ 138 | public function getError(): ?string 139 | { 140 | return $this->error; 141 | } 142 | 143 | /** 144 | * @return int 145 | */ 146 | public function getExactTime(): int 147 | { 148 | return $this->exactTime; 149 | } 150 | 151 | /** 152 | * @return DateTimeImmutable 153 | */ 154 | public function getCreatedAt(): DateTimeImmutable 155 | { 156 | return $this->createdAt; 157 | } 158 | 159 | /** 160 | * @return int 161 | */ 162 | public function getAttempts(): int 163 | { 164 | return $this->attempts; 165 | } 166 | 167 | /** 168 | * @return string 169 | */ 170 | public function getQueue(): string 171 | { 172 | return $this->queue; 173 | } 174 | 175 | /** 176 | * @return string|null 177 | */ 178 | public function getEvent(): ?string 179 | { 180 | return $this->event; 181 | } 182 | 183 | /** 184 | * @return string 185 | */ 186 | public function getBody(): string 187 | { 188 | return $this->body; 189 | } 190 | 191 | /** 192 | * @return int 193 | */ 194 | public function getPriority(): int 195 | { 196 | return (int)((string)$this->priority); 197 | } 198 | 199 | /** 200 | * @return DateTimeImmutable|null 201 | */ 202 | public function getRedeliveredAt(): ?DateTimeImmutable 203 | { 204 | return $this->redeliveredAt; 205 | } 206 | 207 | /** 208 | * @return bool 209 | */ 210 | public function isRedelivered(): bool 211 | { 212 | if ((string)$this->status === Status::REDELIVERED) { 213 | return true; 214 | } 215 | 216 | return $this->redeliveredAt ? true : false; 217 | } 218 | 219 | /** 220 | * @return bool 221 | */ 222 | public function isJob(): bool 223 | { 224 | return $this->isJob; 225 | } 226 | 227 | /** 228 | * @param DateTimeImmutable|null $redeliveredAt 229 | * @return $this 230 | */ 231 | public function changeRedeliveredAt(?DateTimeImmutable $redeliveredAt): self 232 | { 233 | $this->redeliveredAt = $redeliveredAt; 234 | 235 | return $this; 236 | } 237 | 238 | /** 239 | * @param string $queue 240 | * @return $this 241 | */ 242 | public function changeQueue(string $queue): self 243 | { 244 | $this->queue = $queue; 245 | 246 | return $this; 247 | } 248 | 249 | /** 250 | * @param int $priority 251 | * @return $this 252 | */ 253 | public function changePriority(int $priority): self 254 | { 255 | $this->priority = new Priority($priority); 256 | 257 | return $this; 258 | } 259 | 260 | /** 261 | * @param string|null $event 262 | * @return $this 263 | */ 264 | public function withEvent(?string $event): self 265 | { 266 | $this->event = $event; 267 | 268 | return $this; 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | serializer === null) { 37 | $this->serializer = new BaseSerializer(); 38 | } 39 | } 40 | 41 | /** 42 | * @return static 43 | */ 44 | public static function getDefault(): self 45 | { 46 | return new self; 47 | } 48 | 49 | /** 50 | * @return int 51 | */ 52 | public function getRedeliveryTimeInSeconds(): int 53 | { 54 | return $this->redeliveryTimeInSeconds; 55 | } 56 | 57 | /** 58 | * @return int 59 | */ 60 | public function getNumberOfAttemptsBeforeFailure(): int 61 | { 62 | return $this->numberOfAttemptsBeforeFailure; 63 | } 64 | 65 | /** 66 | * @param int $seconds 67 | * @return $this 68 | */ 69 | public function changeRedeliveryTimeInSeconds(int $seconds): self 70 | { 71 | $this->redeliveryTimeInSeconds = $seconds; 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * @param int $attempt 78 | * @return $this 79 | */ 80 | public function changeNumberOfAttemptsBeforeFailure(int $attempt): self 81 | { 82 | $this->numberOfAttemptsBeforeFailure = $attempt; 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * @return SerializerInterface 89 | */ 90 | public function getSerializer(): SerializerInterface 91 | { 92 | return $this->serializer; 93 | } 94 | 95 | /** 96 | * @param SerializerInterface $serializer 97 | * @return $this 98 | */ 99 | public function withSerializer(SerializerInterface $serializer): self 100 | { 101 | $this->serializer = $serializer; 102 | 103 | return $this; 104 | } 105 | 106 | /** 107 | * @param string $jobName 108 | * @param Job $job 109 | * @return $this 110 | * @throws ConfigException 111 | */ 112 | public function registerJob(string $jobName, Job $job): self 113 | { 114 | if (isset($this->jobs[$jobName])) { 115 | throw new ConfigException(sprintf('Job "%s" is already registered.', $jobName)); 116 | } 117 | 118 | if (class_exists($jobName) === false && (bool)preg_match('/^[a-zA-Z0-9_.-]*$/u', $jobName) === false) { 119 | throw new ConfigException(sprintf('Job alias "%s" contains invalid characters.', $jobName)); 120 | } 121 | 122 | $this->jobs[$jobName] = $job; 123 | 124 | return $this; 125 | } 126 | 127 | /** 128 | * @return array 129 | */ 130 | public function getJobs(): array 131 | { 132 | return $this->jobs; 133 | } 134 | 135 | /** 136 | * @param string $jobName 137 | * @return Job 138 | * @throws QueueException 139 | */ 140 | public function getJob(string $jobName): Job 141 | { 142 | if ($this->hasJob($jobName) === false) { 143 | throw new QueueException(sprintf('Job "%s" not registered.', $jobName)); 144 | } 145 | 146 | if (class_exists($jobName)) { 147 | foreach ($this->jobs as $jobAlias => $job) { 148 | if (is_a($job, $jobName)) { 149 | return $job; 150 | } 151 | } 152 | } 153 | 154 | return $this->jobs[$jobName]; 155 | } 156 | 157 | /** 158 | * @param string $jobName 159 | * @return bool 160 | */ 161 | public function hasJob(string $jobName): bool 162 | { 163 | if (class_exists($jobName)) { 164 | foreach ($this->jobs as $jobAlias => $job) { 165 | if (is_a($job, $jobName)) { 166 | return true; 167 | } 168 | } 169 | } 170 | 171 | return isset($this->jobs[$jobName]); 172 | } 173 | 174 | /** 175 | * @param string $jobName 176 | * @return string 177 | * @throws ConfigException 178 | */ 179 | public function getJobAlias(string $jobName): string 180 | { 181 | if (isset($this->jobs[$jobName])) { 182 | return $jobName; 183 | } 184 | 185 | foreach ($this->jobs as $jobAlias => $job) { 186 | if (is_a($job, $jobName)) { 187 | return $jobAlias; 188 | } 189 | } 190 | 191 | throw new ConfigException(sprintf('Job "%s" not registered.', $jobName)); 192 | } 193 | 194 | /** 195 | * @param string $queue 196 | * @param callable $processor 197 | * @return $this 198 | * @throws ConfigException 199 | */ 200 | public function registerProcessor(string $queue, callable $processor): self 201 | { 202 | if ($this->hasProcessor($queue)) { 203 | throw new ConfigException(sprintf('Processor "%s" is already registered.', $queue)); 204 | } 205 | 206 | $this->processors[$queue] = $processor; 207 | 208 | return $this; 209 | } 210 | 211 | /** 212 | * @return array 213 | */ 214 | public function getProcessors(): array 215 | { 216 | return $this->processors; 217 | } 218 | 219 | /** 220 | * @param string $queue 221 | * @return callable 222 | * @throws ConfigException 223 | */ 224 | public function getProcessor(string $queue): callable 225 | { 226 | if ($this->hasProcessor($queue) === false) { 227 | throw new ConfigException(sprintf('Processor "%s" not registered.', $queue)); 228 | } 229 | 230 | return $this->processors[$queue]; 231 | } 232 | 233 | /** 234 | * @param string $queue 235 | * @return bool 236 | */ 237 | public function hasProcessor(string $queue): bool 238 | { 239 | return isset($this->processors[$queue]); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/Consumer.php: -------------------------------------------------------------------------------- 1 | transport = $transport; 50 | $this->producer = $producer; 51 | $this->config = $config ?: Config::getDefault(); 52 | } 53 | 54 | /** 55 | * The message has been successfully processed and will be removed from the queue 56 | * 57 | * @param Message $message 58 | */ 59 | public function acknowledge(Message $message): void 60 | { 61 | $this->transport->deleteMessage($message); 62 | } 63 | 64 | /** 65 | * Reject message with requeue option 66 | * 67 | * @param Message $message 68 | * @param bool $requeue 69 | */ 70 | public function reject(Message $message, bool $requeue = false): void 71 | { 72 | $this->acknowledge($message); 73 | 74 | if ($requeue) { 75 | $redeliveryMessage = $this->producer->makeRedeliveryMessage($message); 76 | $this->producer->send($redeliveryMessage); 77 | } 78 | } 79 | 80 | /** 81 | * TODO: pass the processing message status to $eachCallback 82 | * 83 | * @param array $queues 84 | * @param callable|null $eachCallback 85 | */ 86 | public function consume(array $queues = [], ?callable $eachCallback = null): void 87 | { 88 | $this->transport->init(); 89 | 90 | while (true) { 91 | if ($message = $this->transport->fetchMessage($queues)) { 92 | try { 93 | $this->processing($message); 94 | } catch (Throwable $throwable) { 95 | try { 96 | $this->processFailureResult($throwable, $message); 97 | } catch (\Doctrine\DBAL\Exception $exception) { 98 | // maybe lucky later 99 | } 100 | } 101 | $eachCallback && $eachCallback($message, $throwable ?? null); 102 | continue; 103 | } 104 | usleep(200000); // 0.2 second 105 | } 106 | } 107 | 108 | /** 109 | * @param Message $message 110 | */ 111 | protected function processing(Message $message): void 112 | { 113 | $this->transport->changeMessageStatus($message, new Status(Status::IN_PROCESS)); 114 | 115 | if ($message->isJob()) { 116 | try { 117 | $job = $this->config->getJob($message->getEvent()); 118 | $result = $job->handle($this->getContext($message)); 119 | $this->processSuccessResult($result, $message); 120 | } catch (Throwable $exception) { 121 | $this->processFailureResult($exception, $message); 122 | } 123 | 124 | return; 125 | } 126 | 127 | if ($this->config->hasProcessor($message->getQueue())) { 128 | try { 129 | $result = $this->config->getProcessor($message->getQueue())($this->getContext($message)); 130 | $this->processSuccessResult($result, $message); 131 | } catch (Throwable $exception) { 132 | $this->processFailureResult($exception, $message); 133 | } 134 | 135 | return; 136 | } 137 | 138 | $this->processUndefinedHandlerResult($message); 139 | } 140 | 141 | /** 142 | * @param Message $message 143 | */ 144 | protected function processUndefinedHandlerResult(Message $message): void 145 | { 146 | MessageHydrator::changeProperty($message, 'status', new Status(Status::UNDEFINED_HANDLER)); 147 | MessageHydrator::changeProperty($message, 'error', 'Could not find any job or processor.'); 148 | 149 | $this->reject($message, true); 150 | } 151 | 152 | /** 153 | * @param Throwable $exception 154 | * @param Message $message 155 | */ 156 | protected function processFailureResult(Throwable $exception, Message $message): void 157 | { 158 | $newStatus = Status::REDELIVERED; 159 | 160 | $numberOfAttemptsBeforeFailure = $this->config->getNumberOfAttemptsBeforeFailure(); 161 | 162 | if ($message->isJob()) { 163 | $job = $this->config->getJob($message->getEvent()); 164 | if ($job->attempts()) { 165 | $numberOfAttemptsBeforeFailure = $job->attempts(); 166 | } 167 | } 168 | 169 | if (($message->getAttempts() + 1) >= $numberOfAttemptsBeforeFailure) { 170 | $newStatus = Status::FAILURE; 171 | } 172 | 173 | MessageHydrator::changeProperty($message, 'status', new Status($newStatus)); 174 | MessageHydrator::changeProperty($message, 'error', (string)$exception); 175 | 176 | $this->reject($message, true); 177 | } 178 | 179 | /** 180 | * @param string $status 181 | * @param Message $message 182 | */ 183 | protected function processSuccessResult(string $status, Message $message): void 184 | { 185 | if ($status === self::STATUS_ACK) { 186 | $this->acknowledge($message); 187 | 188 | return; 189 | } 190 | 191 | if ($status === self::STATUS_REJECT) { 192 | $this->reject($message); 193 | 194 | return; 195 | } 196 | 197 | if ($status === self::STATUS_REQUEUE) { 198 | $this->reject($message, true); 199 | 200 | return; 201 | } 202 | 203 | throw new InvalidArgumentException(sprintf('Unsupported result status: "%s".', $status)); 204 | } 205 | 206 | /** 207 | * @param Message $message 208 | * @return Context 209 | */ 210 | protected function getContext(Message $message): Context 211 | { 212 | $data = $this->config->getSerializer()->deserialize($message->getBody()); 213 | 214 | return new Context( 215 | $this->producer, 216 | $message, 217 | is_array($data) ? $data : [$data] 218 | ); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /docs/guide/consuming.md: -------------------------------------------------------------------------------- 1 | PHP Simple Queue Usage basics 2 | ============================= 3 | 4 | Message consumer is an object that is used for receiving messages sent to a destination (processor or job). 5 | 6 | 7 | ## :book: Guide 8 | 9 | * [Guide](./README.md) 10 | * [Install](./install.md) 11 | * [Transport](./transport.md) 12 | * [Configuration](./configuration.md) 13 | * [Producer (Send message)](./producer.md) 14 | * **[Consuming](./consuming.md)** 15 | * [Example](./example.md) 16 | * [Cookbook](./cookbook.md) 17 | 18 |
19 | 20 | ## Consuming 21 | 22 | You need to configure [$transport](./transport.md) and [$config](./configuration.md) to read and processing messages from the queue. 23 | [Detailed information](./configuration.md). 24 | 25 | You can use a simple php cli, [Symfony/Console](https://symfony.com/doc/current/components/console.html) 26 | or any other component, it really doesn't matter. 27 | The main idea is to run Consumer in a separate process in the background. 28 | 29 | 30 | Use your imagination to handling your messages. 31 | 32 | 33 |
34 | 35 | **Simple example for consuming with processors:** 36 | ------------------------------- 37 | 38 | Processor is responsible for processing consumed messages. 39 | 40 | ```php 41 | consume(); 53 | ``` 54 | 55 |
56 | 57 | **Job processing:** 58 | ------------------------------- 59 | You can create and run a sub job, which it is executed separately. You can create as many sub jobs as you like. 60 | Also, the job can be from another job. All jobs must be registered in the [configuration](./configuration.md). 61 | The method ```$consumer->consume()``` will process jobs with priority. 62 | 63 | **Job example:** 64 | 65 | ```php 66 | 94 | 95 | **Consuming algorithm:** 96 | ------------------------------- 97 | 98 | ```$consumer->consume();``` - base realization of consumer. 99 | 100 | If the message table does not exist, it will be created. 101 | 102 | Next, will start endless loop ```while(true)``` to get the next message from the queue. 103 | if there are no messages, there will be a sustained seconds pause. 104 | 105 | When the message is received, it will be processed. Job has priority over the processor. 106 | 107 | If an uncaught error occurs, it will be caught and increment first processing attempt. 108 | 109 | After several unsuccessful attempts, the message will status `\Simple\Queue\Status::FAILURE`. 110 | 111 | If there are no handlers for the message, the message will status `\Simple\Queue\Status::UNDEFINED_HANDLER`. 112 | 113 | > Messages are processed with statuses: `\Simple\Queue\Status::NEW` and `\Simple\Queue\Status::REDELIVERED` 114 | 115 |
116 | 117 | **Custom example for consuming:** 118 | ------------------------------- 119 | 120 | You can configure message handling yourself. 121 | 122 | ```php 123 | init(); 134 | 135 | echo 'Start consuming' . PHP_EOL; 136 | 137 | while (true) { 138 | 139 | if ($message = $transport->fetchMessage(['my_queue'])) { 140 | 141 | // Your message handling logic 142 | 143 | $consumer->acknowledge($message); 144 | 145 | echo sprintf('Received message: %s ', $message->getBody()); 146 | echo PHP_EOL; 147 | } 148 | 149 | } 150 | ``` 151 | 152 |
153 | 154 | ## Message processing statuses 155 | 156 | If you use jobs or processors when processing a message, you must return the appropriate status: 157 | 158 | * **ACK** - `\Simple\Queue\Consumer::STATUS_ACK` - message has been successfully processed and will be removed from the queue. 159 | 160 | 161 | * **REJECT** - `\Simple\Queue\Consumer::STATUS_REJECT` - message has not been processed but is no longer required. 162 | 163 | 164 | * **REQUEUE** - `\Simple\Queue\Consumer::STATUS_REQUEUE` - message has not been processed, it is necessary redelivered. 165 | 166 |
167 | 168 | ## Run in background process 169 | 170 | A consumer can be run in the background in several ways: 171 | 172 | - [using php cli](#Using-php-cli) 173 | - [run the application in a daemonized docker container](#Run-the-application-in-a-daemonized-docker-container) 174 | - [using supervisor](#Using-supervisor) 175 | 176 | 177 | 178 | ### Using php cli 179 | Configure your consume.php and run the command 180 | 181 | ```bash 182 | exec php /path/to/folder/example/consume.php > /dev/null & 183 | ``` 184 | the result of a successful launch of the command will be the process code, for example: 185 | 186 | ```bash 187 | [1] 97285 188 | ``` 189 | 190 | use this to get detailed information about the process. 191 | ```bash 192 | ps -l 97285 193 | ``` 194 | 195 | 196 | ### Run the application in a daemonized docker container 197 | 198 | This command will allow your docker container to run in the background: 199 | 200 | ```bash 201 | docker exec -t -d you-container-name sh -c "php ./path/to/consume.php" 202 | ``` 203 | 204 | 205 | ### Using supervisor 206 | 207 | Сonfigure your supervisor config file `/etc/supervisor/conf.d/consume.conf` 208 | ```bash 209 | [program:consume] 210 | command=/usr/bin/php /path/to/folder/example/consume.php -DFOREGROUND 211 | directory=/path/to/folder/example/ 212 | autostart=true 213 | autorestart=true 214 | startretries=5 215 | user=root 216 | numprocs=1 217 | startsecs=0 218 | process_name=%(program_name)s_%(process_num)02d 219 | stderr_logfile=/path/to/folder/example/%(program_name)s_stderr.log 220 | stderr_logfile_maxbytes=10MB 221 | stdout_logfile=/path/to/folder/example/%(program_name)s_stdout.log 222 | stdout_logfile_maxbytes=10MB 223 | ``` 224 | 225 | Let supervisor read our config file `/etc/supervisor/conf.d/consume.conf` to start our service/script. 226 | 227 | ```bash 228 | $ sudo supervisorctl reread 229 | consume: available 230 | ``` 231 | 232 | Let supervisor start our service/script `/path/to/folder/example/consume.php` based on the config we prepared above. 233 | This will automatically create log files `/path/to/folder/example/consume_stderr.log` and 234 | `/path/to/folder/example/consume_stdout.log`. 235 | 236 | ```bash 237 | $ sudo supervisorctl update 238 | consume: added process group 239 | ``` 240 | 241 | Lets check if the process is running. 242 | 243 | ```bash 244 | $ ps aux | grep consume 245 | root 17443 0.0 0.4 194836 9844 ? S 19:41 0:00 /usr/bin/php /path/to/folder/example/consume.php 246 | ``` -------------------------------------------------------------------------------- /tests/ConfigTest.php: -------------------------------------------------------------------------------- 1 | changeRedeliveryTimeInSeconds(250) 27 | ->changeNumberOfAttemptsBeforeFailure(4) 28 | ->withSerializer($this->createMock(SerializerInterface::class)); 29 | 30 | self::assertEquals(250, $config->getRedeliveryTimeInSeconds()); 31 | self::assertEquals(4, $config->getNumberOfAttemptsBeforeFailure()); 32 | self::assertInstanceOf(SerializerInterface::class, $config->getSerializer()); 33 | } 34 | 35 | public function testDefaultInstance(): void 36 | { 37 | $config = new Config(); 38 | 39 | self::assertEquals($config, Config::getDefault()); 40 | } 41 | 42 | public function testDefaultRedeliveryTimeInSeconds(): void 43 | { 44 | self::assertEquals(180, Config::getDefault()->getRedeliveryTimeInSeconds()); 45 | } 46 | 47 | public function testDefaultNumberOfAttemptsBeforeFailure(): void 48 | { 49 | self::assertEquals(5, Config::getDefault()->getNumberOfAttemptsBeforeFailure()); 50 | } 51 | 52 | public function testDefaultGetJobs(): void 53 | { 54 | self::assertEquals([], Config::getDefault()->getJobs()); 55 | } 56 | 57 | public function testSeveralGetJobs(): void 58 | { 59 | $job1 = new class extends Job { 60 | public function handle(Context $context): string 61 | { 62 | return Consumer::STATUS_ACK; 63 | } 64 | }; 65 | 66 | $job2 = new class extends Job { 67 | public function handle(Context $context): string 68 | { 69 | return Consumer::STATUS_ACK; 70 | } 71 | }; 72 | 73 | $config = Config::getDefault(); 74 | $config->registerJob('myJob1', $job1); 75 | $config->registerJob('myJob2', $job2); 76 | 77 | self::assertEquals(['myJob1' => $job1, 'myJob2' => $job2], $config->getJobs()); 78 | } 79 | 80 | public function testDefaultSerializer(): void 81 | { 82 | self::assertInstanceOf(BaseSerializer::class, Config::getDefault()->getSerializer()); 83 | } 84 | 85 | public function testGetNotRegistrationJob(): void 86 | { 87 | $this->expectException(QueueException::class); 88 | $this->expectExceptionMessage(sprintf('Job "%s" not registered.', 'not-exists')); 89 | 90 | Config::getDefault()->getJob('not-exists'); 91 | } 92 | 93 | public function testHasNotRegistrationJob(): void 94 | { 95 | self::assertFalse(Config::getDefault()->hasJob('not-exists')); 96 | } 97 | 98 | public function testRegistrationJob(): void 99 | { 100 | $job = new class extends Job { 101 | public function handle(Context $context): string 102 | { 103 | return Consumer::STATUS_ACK; 104 | } 105 | }; 106 | 107 | $config = Config::getDefault(); 108 | $config->registerJob('myJob', $job); 109 | $config->registerJob(get_class($job), $job); 110 | 111 | self::assertEquals($job, $config->getJob('myJob')); 112 | self::assertEquals($job, $config->getJob(get_class($job))); 113 | } 114 | 115 | public function testRegistrationExistJob(): void 116 | { 117 | $this->expectException(ConfigException::class); 118 | $this->expectExceptionMessage(sprintf('Job "%s" is already registered.', 'myJob')); 119 | 120 | $job = new class extends Job { 121 | public function handle(Context $context): string 122 | { 123 | return Consumer::STATUS_ACK; 124 | } 125 | }; 126 | 127 | $config = Config::getDefault(); 128 | $config->registerJob('myJob', $job); 129 | $config->registerJob('myJob', $job); 130 | } 131 | 132 | public function testRegistrationJobWithIncorrectAlias(): void 133 | { 134 | $this->expectException(ConfigException::class); 135 | $this->expectExceptionMessage(sprintf('Job alias "%s" contains invalid characters.', '!@#$%^&*()_+')); 136 | 137 | $job = new class extends Job { 138 | public function handle(Context $context): string 139 | { 140 | return Consumer::STATUS_ACK; 141 | } 142 | }; 143 | 144 | $config = Config::getDefault(); 145 | $config->registerJob('!@#$%^&*()_+', $job); 146 | } 147 | 148 | public function testGetAliasInRegistrationJob(): void 149 | { 150 | $job = new class extends Job { 151 | public function handle(Context $context): string 152 | { 153 | return Consumer::STATUS_ACK; 154 | } 155 | }; 156 | 157 | $config = Config::getDefault(); 158 | $config->registerJob('alias', $job); 159 | 160 | self::assertEquals('alias', $config->getJobAlias('alias')); 161 | self::assertEquals('alias', $config->getJobAlias(get_class($job))); 162 | } 163 | 164 | public function testGetIncorrectAliasInRegistrationJob(): void 165 | { 166 | $this->expectException(ConfigException::class); 167 | $this->expectExceptionMessage(sprintf('Job "%s" not registered.', 'non-alias')); 168 | 169 | Config::getDefault()->getJobAlias('non-alias'); 170 | } 171 | 172 | public function testRegistrationProcessor(): void 173 | { 174 | $config = Config::getDefault(); 175 | 176 | $config->registerProcessor('my_queue', static function (): void { 177 | }); 178 | 179 | self::assertTrue($config->hasProcessor('my_queue')); 180 | } 181 | 182 | public function testRegistrationExistProcessor(): void 183 | { 184 | $this->expectException(ConfigException::class); 185 | $this->expectExceptionMessage(sprintf('Processor "%s" is already registered.', 'my_queue')); 186 | 187 | $config = Config::getDefault(); 188 | 189 | $config->registerProcessor('my_queue', static function (): void { 190 | }); 191 | 192 | $config->registerProcessor('my_queue', static function (): void { 193 | }); 194 | } 195 | 196 | public function testGetExistentProcessor(): void 197 | { 198 | $config = Config::getDefault(); 199 | 200 | $config->registerProcessor('my_queue', static function (): void { 201 | }); 202 | 203 | self::assertIsCallable($config->getProcessor('my_queue')); 204 | } 205 | 206 | public function testGetNonExistentProcessor(): void 207 | { 208 | $this->expectException(ConfigException::class); 209 | $this->expectExceptionMessage(sprintf('Processor "%s" not registered.', 'my_queue')); 210 | 211 | self::assertIsCallable(Config::getDefault()->getProcessor('my_queue')); 212 | } 213 | 214 | public function testHasExistentProcessor(): void 215 | { 216 | $config = Config::getDefault(); 217 | 218 | $config->registerProcessor('my_queue', static function (): void { 219 | }); 220 | 221 | self::assertTrue($config->hasProcessor('my_queue')); 222 | } 223 | 224 | public function testHasNonExistentProcessor(): void 225 | { 226 | self::assertFalse(Config::getDefault()->hasProcessor('my_queue')); 227 | } 228 | 229 | public function testDefaultGetProcessors(): void 230 | { 231 | self::assertEquals([], Config::getDefault()->getProcessors()); 232 | } 233 | 234 | public function testSeveralGetProcessors(): void 235 | { 236 | $config = Config::getDefault(); 237 | 238 | $config->registerProcessor('my_queue1', static function (): void { 239 | }); 240 | 241 | $config->registerProcessor('my_queue2', static function (): void { 242 | }); 243 | 244 | self::assertEquals(['my_queue1', 'my_queue2'], array_keys($config->getProcessors())); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /tests/Transport/DoctrineDbalTransportTest.php: -------------------------------------------------------------------------------- 1 | init(); 34 | 35 | $data = $connection->getSchemaManager()::$data; 36 | 37 | self::assertInstanceOf(Table::class, $data['createTable']); 38 | } 39 | 40 | public function testSend(): void 41 | { 42 | $connection = new MockConnection(null, [ 43 | 'insert' => 1, 44 | ]); 45 | $transport = new DoctrineDbalTransport($connection); 46 | 47 | $transport->send(new Message('my_queue', '')); 48 | 49 | self::assertNotNull($connection::$data['insert']['data']['id']); 50 | self::assertNull($connection::$data['insert']['data']['event']); 51 | self::assertNull($connection::$data['insert']['data']['error']); 52 | self::assertNull($connection::$data['insert']['data']['redelivered_at']); 53 | self::assertEquals(0, $connection::$data['insert']['data']['attempts']); 54 | self::assertEquals('my_queue', $connection::$data['insert']['data']['queue']); 55 | self::assertEquals('', $connection::$data['insert']['data']['body']); 56 | self::assertEquals(Status::NEW, $connection::$data['insert']['data']['status']); 57 | self::assertEquals(Priority::DEFAULT, $connection::$data['insert']['data']['priority']); 58 | self::assertEquals(date('Y-m-d H:i:s'), $connection::$data['insert']['data']['created_at']); 59 | 60 | self::assertEquals(Types::GUID, $connection::$data['insert']['types']['id']); 61 | self::assertEquals(Types::STRING, $connection::$data['insert']['types']['status']); 62 | self::assertEquals(Types::STRING, $connection::$data['insert']['types']['created_at']); 63 | self::assertEquals(Types::STRING, $connection::$data['insert']['types']['redelivered_at']); 64 | self::assertEquals(Types::SMALLINT, $connection::$data['insert']['types']['attempts']); 65 | self::assertEquals(Types::STRING, $connection::$data['insert']['types']['queue']); 66 | self::assertEquals(Types::STRING, $connection::$data['insert']['types']['event']); 67 | self::assertEquals(Types::BOOLEAN, $connection::$data['insert']['types']['is_job']); 68 | self::assertEquals(Types::TEXT, $connection::$data['insert']['types']['body']); 69 | self::assertEquals(Types::SMALLINT, $connection::$data['insert']['types']['priority']); 70 | self::assertEquals(Types::TEXT, $connection::$data['insert']['types']['error']); 71 | self::assertEquals(Types::BIGINT, $connection::$data['insert']['types']['exact_time']); 72 | } 73 | 74 | public function testFetchMessageWithQueueList(): void 75 | { 76 | $connection = new MockConnection(null, [ 77 | 'fetchAssociative' => [ 78 | 'queue' => 'default', 79 | 'event' => null, 80 | 'is_job' => false, 81 | 'body' => '', 82 | 'error' => null, 83 | 'attempts' => 0, 84 | 'status' => Status::NEW, 85 | 'priority' => Priority::DEFAULT, 86 | 'exact_time' => time(), 87 | 'created_at' => (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'), 88 | 'redelivered_at' => null, 89 | ], 90 | ]); 91 | 92 | $transport = new DoctrineDbalTransport($connection); 93 | 94 | $message = $transport->fetchMessage(['my_queue']); 95 | 96 | self::assertEquals('default', $message->getQueue()); 97 | self::assertEquals(false, $message->isJob()); 98 | self::assertEquals(null, $message->getError()); 99 | self::assertEquals(0, $message->getAttempts()); 100 | self::assertEquals(Status::NEW, $message->getStatus()); 101 | self::assertEquals(Priority::DEFAULT, $message->getPriority()); 102 | } 103 | 104 | public function testFetchMessageWithException(): void 105 | { 106 | $expectExceptionMessage = null; 107 | 108 | try { 109 | MessageHydrator::createMessage([ 110 | 'bad data' 111 | ]); 112 | } catch (\Throwable $throwable) { 113 | $expectExceptionMessage = $throwable->getMessage(); 114 | } 115 | 116 | $this->expectException(TransportException::class); 117 | $this->expectExceptionMessage(sprintf('Error reading queue in consumer: "%s".', $expectExceptionMessage)); 118 | 119 | $connection = new MockConnection(null, [ 120 | 'fetchAssociative' => [ 121 | 'bad data' 122 | ], 123 | ]); 124 | 125 | $transport = new DoctrineDbalTransport($connection); 126 | 127 | $message = $transport->fetchMessage(['my_queue']); 128 | 129 | self::assertNull($message); 130 | } 131 | 132 | public function testFetchMessageEmpty(): void 133 | { 134 | $connection = new MockConnection(null, [ 135 | 'fetchAssociative' => [], 136 | ]); 137 | 138 | $transport = new DoctrineDbalTransport($connection); 139 | 140 | $message = $transport->fetchMessage(['my_queue']); 141 | 142 | self::assertNull($message); 143 | } 144 | 145 | public function testChangeUndefinedMessageStatus(): void 146 | { 147 | $this->expectException(QueueException::class); 148 | $this->expectExceptionMessage('The message has no id. It looks like it was not sent to the queue.'); 149 | 150 | $transport = new DoctrineDbalTransport(new MockConnection()); 151 | 152 | $transport->changeMessageStatus(new Message('my_queue', ''), new Status(Status::IN_PROCESS)); 153 | } 154 | 155 | public function testChangeMessageStatus(): void 156 | { 157 | $connection = new MockConnection(); 158 | 159 | $transport = new DoctrineDbalTransport($connection); 160 | 161 | $message = new Message('my_queue', ''); 162 | MessageHydrator::changeProperty($message, 'id', Uuid::uuid4()->toString()); 163 | 164 | $transport->changeMessageStatus($message, new Status(Status::IN_PROCESS)); 165 | 166 | self::assertEquals($message->getId(), $connection::$data['update']['criteria']['id']); 167 | self::assertEquals(Status::IN_PROCESS, $connection::$data['update']['data']['status']); 168 | } 169 | 170 | public function testDeleteUndefinedMessage(): void 171 | { 172 | $this->expectException(QueueException::class); 173 | $this->expectExceptionMessage('The message has no id. It looks like it was not sent to the queue.'); 174 | 175 | $transport = new DoctrineDbalTransport(new MockConnection()); 176 | 177 | $transport->deleteMessage(new Message('my_queue', '')); 178 | } 179 | 180 | public function testDeleteMessage(): void 181 | { 182 | $connection = new MockConnection(); 183 | 184 | $transport = new DoctrineDbalTransport($connection); 185 | 186 | $message = new Message('my_queue', ''); 187 | MessageHydrator::changeProperty($message, 'id', Uuid::uuid4()->toString()); 188 | 189 | $transport->deleteMessage($message); 190 | 191 | self::assertEquals($message->getId(), $connection::$data['delete']['criteria']['id']); 192 | } 193 | 194 | public function testSendWithRedeliveredAt(): void 195 | { 196 | $connection = new MockConnection(null, [ 197 | 'insert' => 1, 198 | ]); 199 | $transport = new DoctrineDbalTransport($connection); 200 | 201 | $redeliveredAt = new DateTimeImmutable('now'); 202 | $message = (new Message('my_queue', ''))->changeRedeliveredAt($redeliveredAt); 203 | 204 | $transport->send($message); 205 | 206 | self::assertEquals($redeliveredAt->format('Y-m-d H:i:s'), $connection::$data['insert']['data']['redelivered_at']); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /tests/ProducerTest.php: -------------------------------------------------------------------------------- 1 | getMockConnectionWithInsert(1); 33 | 34 | $transport = new class($connection) extends DoctrineDbalTransport { 35 | public static Message $message; 36 | 37 | public function send(Message $message): void 38 | { 39 | self::$message = $message; 40 | } 41 | }; 42 | 43 | $producer = new Producer($transport); 44 | $message = $producer->createMessage('my_queue', ''); 45 | 46 | $producer->send($message); 47 | 48 | self::assertEquals($transport::$message, $message); 49 | } 50 | 51 | public function testBodyAsObjectWithMethodToString(): void 52 | { 53 | $connection = $this->getMockConnectionWithInsert(1); 54 | 55 | $transport = new DoctrineDbalTransport($connection); 56 | 57 | $body = new class() { 58 | public string $data = 'my_data'; 59 | 60 | public function __toString(): string 61 | { 62 | return json_encode(['data' => $this->data], JSON_THROW_ON_ERROR); 63 | } 64 | }; 65 | 66 | $producer = new Producer($transport); 67 | $producer->send($producer->createMessage('my_queue', $body)); 68 | 69 | self::assertEquals('queue', ($connection::$data)['insert'][0]); 70 | self::assertEquals('{"data":"my_data"}', ($connection::$data)['insert'][1]['body']); 71 | } 72 | 73 | public function testBodyAsArray(): void 74 | { 75 | $connection = $this->getMockConnectionWithInsert(1); 76 | 77 | $transport = new DoctrineDbalTransport($connection); 78 | 79 | $producer = new Producer($transport); 80 | $producer->send($producer->createMessage('my_queue', ['my_data'])); 81 | 82 | self::assertEquals('queue', ($connection::$data)['insert'][0]); 83 | self::assertEquals( 84 | Config::getDefault()->getSerializer()->serialize(['my_data']), 85 | ($connection::$data)['insert'][1]['body'] 86 | ); 87 | } 88 | 89 | public function testCallableBody(): void 90 | { 91 | $connection = $this->getMockConnectionWithInsert(1); 92 | 93 | $transport = new DoctrineDbalTransport($connection); 94 | 95 | $this->expectException(QueueException::class); 96 | $this->expectExceptionMessage('The closure cannot be serialized.'); 97 | 98 | (new Producer($transport))->createMessage('my_queue', static function (): void { 99 | }); 100 | } 101 | 102 | public function testFailureSendMessage(): void 103 | { 104 | $connection = $this->getMockConnectionWithInsert(0); 105 | 106 | $transport = new DoctrineDbalTransport($connection); 107 | 108 | $this->expectException(TransportException::class); 109 | $previousMessage = 'The message was not enqueued. Dbal did not confirm that the record is inserted.'; 110 | $this->expectExceptionMessage(sprintf('The transport fails to send the message: %s', $previousMessage)); 111 | 112 | $producer = new Producer($transport); 113 | $producer->send(new Message('my_queue', '')); 114 | } 115 | 116 | public function testDispatch(): void 117 | { 118 | $connection = $this->getMockConnectionWithInsert(1); 119 | 120 | $transport = new DoctrineDbalTransport($connection); 121 | 122 | $job = new class() extends Job { 123 | public function handle(Context $context): string 124 | { 125 | return Consumer::STATUS_ACK; 126 | } 127 | }; 128 | 129 | $config = (new Config())->registerJob('job', $job); 130 | 131 | $producer = new Producer($transport, $config); 132 | $producer->dispatch('job', ['my_data']); 133 | 134 | self::assertEquals('queue', ($connection::$data)['insert'][0]); 135 | self::assertEquals( 136 | Config::getDefault()->getSerializer()->serialize(['my_data']), 137 | ($connection::$data)['insert'][1]['body'] 138 | ); 139 | } 140 | 141 | public function testDispatchWithNoRegisteredJob(): void 142 | { 143 | $connection = $this->getMockConnectionWithInsert(1); 144 | 145 | $transport = new DoctrineDbalTransport($connection); 146 | 147 | $this->expectException(QueueException::class); 148 | $this->expectExceptionMessage(sprintf('Job "%s" not registered.', 'non_registered_job')); 149 | 150 | (new Producer($transport))->dispatch('non_registered_job', []); 151 | } 152 | 153 | public function testDefaultMakeRedeliveryMessage(): void 154 | { 155 | $connection = $this->getMockConnectionWithInsert(1); 156 | 157 | $transport = new DoctrineDbalTransport($connection); 158 | 159 | $message = new Message('my_queue', ''); 160 | 161 | $redeliveryMessage = (new Producer($transport))->makeRedeliveryMessage($message); 162 | 163 | self::assertEquals(Status::REDELIVERED, $redeliveryMessage->getStatus()); 164 | self::assertNotNull($redeliveryMessage->getRedeliveredAt()); 165 | self::assertEquals($message->getAttempts() + 1, $redeliveryMessage->getAttempts()); 166 | } 167 | 168 | public function testMakeRedeliveryMessageWithData(): void 169 | { 170 | $connection = $this->getMockConnectionWithInsert(1); 171 | 172 | $transport = new DoctrineDbalTransport($connection); 173 | 174 | $message = (new MessageHydrator(new Message('my_queue', ''))) 175 | ->changeStatus(Status::IN_PROCESS) 176 | ->setError((string)(new Exception('My error'))) 177 | ->jobable(true) 178 | ->changeAttempts(100) 179 | ->getMessage(); 180 | 181 | $redeliveryMessage = (new Producer($transport))->makeRedeliveryMessage($message); 182 | 183 | self::assertEquals(Status::REDELIVERED, $redeliveryMessage->getStatus()); 184 | self::assertNotNull($redeliveryMessage->getError()); 185 | self::assertTrue($redeliveryMessage->isJob()); 186 | self::assertEquals(100 + 1, $redeliveryMessage->getAttempts()); 187 | self::assertNotNull($redeliveryMessage->getRedeliveredAt()); 188 | self::assertEquals('my_queue', $redeliveryMessage->getQueue()); 189 | } 190 | 191 | public function testMakeRedeliveryMessageWithStatusFailure(): void 192 | { 193 | $connection = $this->getMockConnectionWithInsert(1); 194 | 195 | $transport = new DoctrineDbalTransport($connection); 196 | 197 | $message = (new MessageHydrator(new Message('my_queue', ''))) 198 | ->changeStatus(Status::FAILURE) 199 | ->getMessage(); 200 | 201 | $redeliveryMessage = (new Producer($transport))->makeRedeliveryMessage($message); 202 | 203 | self::assertEquals(Status::FAILURE, $redeliveryMessage->getStatus()); 204 | self::assertNull($redeliveryMessage->getRedeliveredAt()); 205 | } 206 | 207 | public function testMakeRedeliveryMessageWithRedeliveredTimeInPast(): void 208 | { 209 | $connection = $this->getMockConnectionWithInsert(1); 210 | 211 | $transport = new DoctrineDbalTransport($connection); 212 | 213 | $redeliveredAt = (new \DateTimeImmutable())->modify('-10 minutes'); 214 | 215 | $message = (new Message('my_queue', ''))->changeRedeliveredAt($redeliveredAt); 216 | 217 | $redeliveryMessage = (new Producer($transport))->makeRedeliveryMessage($message); 218 | 219 | $redeliveredTime = (new DateTimeImmutable('now')) 220 | ->modify(sprintf('+%s seconds', Config::getDefault()->getRedeliveryTimeInSeconds())); 221 | 222 | self::assertEquals( 223 | $redeliveredTime->format('Y-m-d H:i:s'), 224 | $redeliveryMessage->getRedeliveredAt()->format('Y-m-d H:i:s') 225 | ); 226 | } 227 | 228 | public function testMakeRedeliveryMessageWithRedeliveredTimeInFuture(): void 229 | { 230 | $connection = $this->getMockConnectionWithInsert(1); 231 | 232 | $transport = new DoctrineDbalTransport($connection); 233 | 234 | $redeliveredAt = (new \DateTimeImmutable())->modify('+10 minutes'); 235 | 236 | $message = (new Message('my_queue', ''))->changeRedeliveredAt($redeliveredAt); 237 | 238 | $redeliveryMessage = (new Producer($transport))->makeRedeliveryMessage($message); 239 | 240 | self::assertEquals( 241 | $redeliveredAt->format('Y-m-d H:i:s'), 242 | $redeliveryMessage->getRedeliveredAt()->format('Y-m-d H:i:s') 243 | ); 244 | } 245 | 246 | /** 247 | * @param int $value 248 | * @return MockConnection 249 | */ 250 | private function getMockConnectionWithInsert(int $value): MockConnection 251 | { 252 | return new class($value) extends MockConnection { 253 | private int $value; 254 | 255 | public function __construct(int $value, ?AbstractSchemaManager $abstractSchemaManager = null) 256 | { 257 | $this->value = $value; 258 | parent::__construct($abstractSchemaManager); 259 | } 260 | 261 | public function insert($table, array $data, array $types = []): int 262 | { 263 | self::$data['insert'] = [$table, $data, $types]; 264 | 265 | return $this->value; 266 | } 267 | }; 268 | } 269 | } 270 | --------------------------------------------------------------------------------