├── src
├── HermesException.php
├── SerializeException.php
├── Shutdown
│ ├── ShutdownException.php
│ ├── ShutdownInterface.php
│ ├── SharedFileShutdown.php
│ ├── RedisShutdown.php
│ └── PredisShutdown.php
├── Driver
│ ├── NotSupportedException.php
│ ├── UnknownPriorityException.php
│ ├── SerializerAwareTrait.php
│ ├── MaxItemsTrait.php
│ ├── ShutdownTrait.php
│ ├── DriverInterface.php
│ ├── AmazonSqsDriver.php
│ ├── LazyRabbitMqDriver.php
│ ├── PredisSetDriver.php
│ └── RedisSetDriver.php
├── Handler
│ ├── RetryTrait.php
│ ├── EchoHandler.php
│ └── HandlerInterface.php
├── EmitterInterface.php
├── SerializerInterface.php
├── MessageInterface.php
├── DispatcherInterface.php
├── Emitter.php
├── MessageSerializer.php
├── Message.php
└── Dispatcher.php
├── examples
├── redis
│ ├── shutdown.php
│ ├── processor.php
│ └── emitter.php
├── predis
│ ├── shutdown.php
│ ├── processor.php
│ └── emitter.php
├── rabbitmq
│ ├── processor.php
│ └── emitter.php
└── sqs
│ ├── processor.php
│ └── emitter.php
├── .github
└── workflows
│ ├── linter.yml
│ ├── phpcs.yml
│ ├── phpstan.yml
│ ├── security.yml
│ ├── phpunit.yml
│ └── coverage-pages.yml
├── phpunit.xml
├── coverage.sh
├── LICENSE.md
├── phpstan.neon
├── CONTRIBUTING.md
├── CONDUCT.md
├── composer.json
├── CHANGELOG.md
└── README.md
/src/HermesException.php:
--------------------------------------------------------------------------------
1 | connect('127.0.0.1', 6379);
10 | (new RedisShutdown($redis))->shutdown();
11 |
--------------------------------------------------------------------------------
/src/Handler/RetryTrait.php:
--------------------------------------------------------------------------------
1 | 'tcp',
12 | 'host' => '127.0.0.1',
13 | 'port' => 6379,
14 | ]);
15 | $driver = new PredisSetDriver($redis);
16 | (new PredisShutdown($redis))->shutdown();
17 |
--------------------------------------------------------------------------------
/src/EmitterInterface.php:
--------------------------------------------------------------------------------
1 | getId()} (type {$message->getType()})\n";
18 | $payload = json_encode($message->getPayload());
19 | echo "Payload: {$payload}\n";
20 | return true;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/examples/rabbitmq/processor.php:
--------------------------------------------------------------------------------
1 | registerHandler('type1', new EchoHandler());
18 |
19 | $dispatcher->handle();
20 |
--------------------------------------------------------------------------------
/.github/workflows/linter.yml:
--------------------------------------------------------------------------------
1 | name: PHP Syntax Check
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | lint:
7 | name: PHP Syntax Check
8 | runs-on: ubuntu-latest
9 | strategy:
10 | matrix:
11 | php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3']
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v4
15 |
16 | - name: Setup PHP
17 | uses: shivammathur/setup-php@v2
18 | with:
19 | php-version: ${{ matrix.php-versions }}
20 | coverage: none
21 |
22 | - name: PHP Syntax Check
23 | run: |
24 | find src tests examples -name "*.php" -print0 | xargs -0 -n1 -P8 php -l
25 |
--------------------------------------------------------------------------------
/examples/sqs/processor.php:
--------------------------------------------------------------------------------
1 | 'latest',
13 | 'region' => '*region*',
14 | 'credentials' => [
15 | 'key' => '*key*',
16 | 'secret' => '*secret*',
17 | ]
18 | ]);
19 |
20 | $driver = new AmazonSqsDriver($client, '*queueName*');
21 | $dispatcher = new Dispatcher($driver);
22 |
23 | $dispatcher->registerHandler('type1', new EchoHandler());
24 |
25 | $dispatcher->handle();
26 |
--------------------------------------------------------------------------------
/src/Driver/SerializerAwareTrait.php:
--------------------------------------------------------------------------------
1 | serializer = $serializer;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/examples/rabbitmq/emitter.php:
--------------------------------------------------------------------------------
1 | emit(new Message('type1', ['message' => $counter]));
21 | echo "Emited message $counter\n";
22 | $counter++;
23 | sleep(1);
24 | }
25 |
--------------------------------------------------------------------------------
/src/Handler/HandlerInterface.php:
--------------------------------------------------------------------------------
1 | 'latest',
13 | 'region' => '*region*',
14 | 'credentials' => [
15 | 'key' => '*key*',
16 | 'secret' => '*secret*',
17 | ]
18 | ]);
19 |
20 | $driver = new AmazonSqsDriver($client, '*queueName*');
21 | $emitter = new Emitter($driver);
22 | $counter = 1;
23 | while (true) {
24 | $emitter->emit(new Message('type1', ['message' => $counter]));
25 | echo "Emited message $counter\n";
26 | $counter++;
27 | sleep(1);
28 | }
29 |
--------------------------------------------------------------------------------
/examples/redis/processor.php:
--------------------------------------------------------------------------------
1 | connect('127.0.0.1', 6379);
13 | $driver = new RedisSetDriver($redis, 'hermes', 1);
14 | $driver->setShutdown(new RedisShutdown($redis));
15 | $driver->setupPriorityQueue('hermes_low', \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY - 10);
16 | $driver->setupPriorityQueue('hermes_high', \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY + 10);
17 |
18 | $dispatcher = new Dispatcher($driver);
19 |
20 | $dispatcher->registerHandler('type1', new EchoHandler());
21 |
22 | $dispatcher->handle();
23 | //$dispatcher->handle([Dispatcher::PRIORITY_HIGH]);
24 |
--------------------------------------------------------------------------------
/src/Driver/MaxItemsTrait.php:
--------------------------------------------------------------------------------
1 | maxProcessItems = $count;
15 | }
16 |
17 | public function incrementProcessedItems(): int
18 | {
19 | $this->processed++;
20 | return $this->processed;
21 | }
22 |
23 | public function processed(): int
24 | {
25 | return $this->processed;
26 | }
27 |
28 | public function shouldProcessNext(): bool
29 | {
30 | if ($this->maxProcessItems === 0) {
31 | return true;
32 | }
33 | if ($this->processed >= $this->maxProcessItems) {
34 | return false;
35 | }
36 | return true;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/SerializerInterface.php:
--------------------------------------------------------------------------------
1 | 'tcp',
14 | 'host' => '127.0.0.1',
15 | 'port' => 6379,
16 | ]);
17 | $driver = new PredisSetDriver($redis, 'hermes', 1);
18 | $driver->setShutdown(new PredisShutdown($redis));
19 | $driver->setupPriorityQueue('hermes_low', \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY - 10);
20 | $driver->setupPriorityQueue('hermes_high', \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY + 10);
21 |
22 | $dispatcher = new Dispatcher($driver);
23 |
24 | $dispatcher->registerHandler('type1', new EchoHandler());
25 |
26 | $dispatcher->handle();
27 | //$dispatcher->handle([Dispatcher::PRIORITY_HIGH]);
28 |
--------------------------------------------------------------------------------
/src/Driver/ShutdownTrait.php:
--------------------------------------------------------------------------------
1 | shutdown = $shutdown;
19 | $this->startTime = new DateTime();
20 | }
21 |
22 | private function shouldShutdown(): bool
23 | {
24 | return isset($this->shutdown) && $this->shutdown->shouldShutdown($this->startTime);
25 | }
26 |
27 | /**
28 | * @throws ShutdownException
29 | */
30 | private function checkShutdown(): void
31 | {
32 | if ($this->shouldShutdown()) {
33 | throw new ShutdownException();
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/examples/redis/emitter.php:
--------------------------------------------------------------------------------
1 | connect('127.0.0.1', 6379);
12 | $driver = new RedisSetDriver($redis);
13 | $driver->setupPriorityQueue('hermes_low', \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY - 10);
14 | $driver->setupPriorityQueue('hermes_high', \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY + 10);
15 |
16 | $emitter = new Emitter($driver);
17 |
18 | $counter = 1;
19 | $priorities = [\Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY, \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY - 10, \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY + 10];
20 | while (true) {
21 | $emitter->emit(new Message('type1', ['message' => $counter]), $priorities[rand(0, count($priorities) - 1)]);
22 | echo "Emited message $counter\n";
23 | $counter++;
24 | sleep(1);
25 | }
26 |
--------------------------------------------------------------------------------
/src/Shutdown/ShutdownInterface.php:
--------------------------------------------------------------------------------
1 | 'tcp',
13 | 'host' => '127.0.0.1',
14 | 'port' => 6379,
15 | ]);
16 | $driver = new PredisSetDriver($redis);
17 | $driver->setupPriorityQueue('hermes_low', \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY - 10);
18 | $driver->setupPriorityQueue('hermes_high', \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY + 10);
19 |
20 | $emitter = new Emitter($driver);
21 |
22 | $counter = 1;
23 | $priorities = [\Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY, \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY - 10, \Tomaj\Hermes\Dispatcher::DEFAULT_PRIORITY + 10];
24 | while (true) {
25 | $emitter->emit(new Message('type1', ['message' => $counter]), $priorities[rand(0, count($priorities) - 1)]);
26 | echo "Emited message $counter\n";
27 | $counter++;
28 | sleep(1);
29 | }
30 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 | tests
17 |
18 |
19 |
20 |
22 |
23 | src
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/coverage.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Generate code coverage report locally
4 | echo "Generating code coverage report..."
5 |
6 | # Create build directories
7 | mkdir -p build/logs build/coverage
8 |
9 | # Run PHPUnit with coverage (with Xdebug coverage mode)
10 | XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-clover build/logs/clover.xml --coverage-html build/coverage
11 |
12 | if [ $? -eq 0 ]; then
13 | echo ""
14 | echo "✅ Coverage report generated successfully!"
15 | echo ""
16 | echo "📊 Coverage reports available at:"
17 | echo " - HTML: build/coverage/index.html"
18 | echo " - Clover XML: build/logs/clover.xml"
19 | echo ""
20 | echo "🌐 Open the HTML report in your browser:"
21 | if command -v xdg-open &> /dev/null; then
22 | echo " xdg-open build/coverage/index.html"
23 | elif command -v open &> /dev/null; then
24 | echo " open build/coverage/index.html"
25 | else
26 | echo " file://$(pwd)/build/coverage/index.html"
27 | fi
28 | else
29 | echo ""
30 | echo "❌ Coverage generation failed!"
31 | exit 1
32 | fi
--------------------------------------------------------------------------------
/.github/workflows/phpcs.yml:
--------------------------------------------------------------------------------
1 | name: Code Style Check
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | phpcs:
7 | name: PHP CodeSniffer
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout code
11 | uses: actions/checkout@v4
12 |
13 | - name: Setup PHP
14 | uses: shivammathur/setup-php@v2
15 | with:
16 | php-version: '8.1'
17 | extensions: json
18 | coverage: none
19 |
20 | - name: Validate composer.json and composer.lock
21 | run: composer validate --strict
22 |
23 | - name: Cache Composer packages
24 | id: composer-cache
25 | uses: actions/cache@v3
26 | with:
27 | path: vendor
28 | key: ${{ runner.os }}-php-8.1-${{ hashFiles('**/composer.lock') }}
29 | restore-keys: |
30 | ${{ runner.os }}-php-8.1-
31 |
32 | - name: Install dependencies
33 | run: composer install --prefer-dist --no-progress --no-suggest
34 |
35 | - name: Run PHP CodeSniffer
36 | run: vendor/bin/phpcs --standard=PSR2 src tests examples -n
37 |
--------------------------------------------------------------------------------
/.github/workflows/phpstan.yml:
--------------------------------------------------------------------------------
1 | name: PHPStan
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | phpstan:
7 | name: PHPStan Static Analysis
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout code
11 | uses: actions/checkout@v4
12 |
13 | - name: Setup PHP
14 | uses: shivammathur/setup-php@v2
15 | with:
16 | php-version: '8.1'
17 | extensions: json, redis
18 | coverage: none
19 |
20 | - name: Validate composer.json and composer.lock
21 | run: composer validate --strict
22 |
23 | - name: Cache Composer packages
24 | id: composer-cache
25 | uses: actions/cache@v3
26 | with:
27 | path: vendor
28 | key: ${{ runner.os }}-php-8.1-${{ hashFiles('**/composer.lock') }}
29 | restore-keys: |
30 | ${{ runner.os }}-php-8.1-
31 |
32 | - name: Install dependencies
33 | run: composer install --prefer-dist --no-progress --no-suggest
34 |
35 | - name: Run PHPStan analysis
36 | run: vendor/bin/phpstan analyse --memory-limit=1G
37 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Tomas Majer
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
13 | > all 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
21 | > THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/security.yml:
--------------------------------------------------------------------------------
1 | name: Security Audit
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 | schedule:
9 | - cron: '0 2 * * 1' # Weekly on Monday at 2 AM
10 |
11 | jobs:
12 | security-audit:
13 | name: Security Audit
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@v4
18 |
19 | - name: Setup PHP
20 | uses: shivammathur/setup-php@v2
21 | with:
22 | php-version: '8.1'
23 | extensions: json
24 | coverage: none
25 |
26 | - name: Validate composer.json and composer.lock
27 | run: composer validate --strict
28 |
29 | - name: Cache Composer packages
30 | id: composer-cache
31 | uses: actions/cache@v3
32 | with:
33 | path: vendor
34 | key: ${{ runner.os }}-php-8.1-${{ hashFiles('**/composer.lock') }}
35 | restore-keys: |
36 | ${{ runner.os }}-php-8.1-
37 |
38 | - name: Install dependencies
39 | run: composer install --prefer-dist --no-progress --no-suggest
40 |
41 | - name: Security audit
42 | run: composer audit --format=plain
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | includes:
2 | - vendor/pepakriz/phpstan-exception-rules/extension.neon
3 | - vendor/phpstan/phpstan-strict-rules/rules.neon
4 |
5 | parameters:
6 | level: max
7 | paths:
8 | - src
9 | - examples
10 | # - tests
11 |
12 | treatPhpDocTypesAsCertain: false
13 | checkMissingIterableValueType: true
14 | checkGenericClassInNonGenericObjectType: false
15 | reportUnmatchedIgnoredErrors: false
16 |
17 | # Better type checking (compatible with PHPStan 0.12)
18 | checkAlwaysTrueCheckTypeFunctionCall: true
19 | checkAlwaysTrueInstanceof: true
20 | checkAlwaysTrueStrictComparison: true
21 | checkExplicitMixedMissingReturn: true
22 | checkFunctionNameCase: true
23 | checkInternalClassCaseSensitivity: true
24 |
25 | # Exception rules
26 | exceptionRules:
27 | reportUnusedCatchesOfUncheckedExceptions: true
28 | reportUnusedCheckedThrowsInSubtypes: false
29 | reportCheckedThrowsInGlobalScope: true
30 | checkedExceptions:
31 | - RuntimeException
32 |
33 | # Ignore some legacy driver issues for now
34 | ignoreErrors:
35 | - '#Only booleans are allowed in#'
36 | - '#Call to function in_array\(\) requires parameter \#3 to be set#'
37 | - '#Anonymous function should have native return typehint#'
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/src/Shutdown/SharedFileShutdown.php:
--------------------------------------------------------------------------------
1 | filePath = $filePath;
15 | }
16 |
17 | /**
18 | * {@inheritdoc}
19 | */
20 | public function shouldShutdown(DateTime $startTime): bool
21 | {
22 | clearstatcache(false, $this->filePath);
23 |
24 | if (!file_exists($this->filePath)) {
25 | return false;
26 | }
27 |
28 | $time = filemtime($this->filePath);
29 | if ($time !== false && $time >= $startTime->getTimestamp()) {
30 | return true;
31 | }
32 |
33 | return false;
34 | }
35 |
36 | /**
37 | * {@inheritdoc}
38 | *
39 | * Creates file defined in constructor with modification time `$shutdownTime` (or current DateTime).
40 | */
41 | public function shutdown(?DateTime $shutdownTime = null): bool
42 | {
43 | if ($shutdownTime === null) {
44 | $shutdownTime = new DateTime();
45 | }
46 |
47 | return touch($this->filePath, (int) $shutdownTime->format('U'));
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Contributions are **welcome** and will be fully **credited**.
4 |
5 | We accept contributions via Pull Requests on [Github](https://github.com/thephpleague/:package_name).
6 |
7 |
8 | ## Pull Requests
9 |
10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer).
11 |
12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests.
13 |
14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date.
15 |
16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option.
17 |
18 | - **Create feature branches** - Don't ask us to pull from your master branch.
19 |
20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.
21 |
22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting.
23 |
24 |
25 | ## Running Tests
26 |
27 | ``` bash
28 | $ composer test
29 | ```
30 |
31 |
32 | **Happy coding**!
33 |
--------------------------------------------------------------------------------
/src/MessageInterface.php:
--------------------------------------------------------------------------------
1 |
51 | */
52 | public function getPayload(): ?array;
53 |
54 | /**
55 | * Total retries for message
56 | *
57 | * @return int
58 | */
59 | public function getRetries(): int;
60 | }
61 |
--------------------------------------------------------------------------------
/src/DispatcherInterface.php:
--------------------------------------------------------------------------------
1 | redis = $redis;
23 | $this->key = $key;
24 | }
25 |
26 | /**
27 | * {@inheritdoc}
28 | *
29 | * Returns true:
30 | * - if shutdown timestamp is set,
31 | * - and timestamp is not in future,
32 | * - and hermes was started ($startTime) before timestamp
33 | */
34 | public function shouldShutdown(DateTime $startTime): bool
35 | {
36 | // load UNIX timestamp from redis
37 | $shutdownTime = $this->redis->get($this->key);
38 | if ($shutdownTime === false) {
39 | return false;
40 | }
41 | $shutdownTime = (int) $shutdownTime;
42 |
43 | // do not shutdown if shutdown time is in future
44 | if ($shutdownTime > time()) {
45 | return false;
46 | }
47 |
48 | // do not shutdown if hermes started after shutdown time
49 | if ($shutdownTime < $startTime->getTimestamp()) {
50 | return false;
51 | }
52 |
53 | return true;
54 | }
55 |
56 | /**
57 | * {@inheritdoc}
58 | *
59 | * Sets to Redis value `$shutdownTime` (or current DateTime) to `$key` defined in constructor.
60 | */
61 | public function shutdown(?DateTime $shutdownTime = null): bool
62 | {
63 | if ($shutdownTime === null) {
64 | $shutdownTime = new DateTime();
65 | }
66 |
67 | return $this->redis->set($this->key, $shutdownTime->format('U'));
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/.github/workflows/phpunit.yml:
--------------------------------------------------------------------------------
1 | name: PHPUnit
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | run:
7 | runs-on: ${{ matrix.operating-system }}
8 | strategy:
9 | matrix:
10 | operating-system: [ubuntu-latest]
11 | php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3']
12 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }}
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 |
17 | - name: Setup PHP
18 | uses: shivammathur/setup-php@v2
19 | with:
20 | php-version: ${{ matrix.php-versions }}
21 | ini-values: post_max_size=256M, max_execution_time=180
22 | coverage: xdebug
23 | extensions: json, redis
24 |
25 | - name: Validate composer.json and composer.lock
26 | run: composer validate --strict
27 |
28 | - name: Cache Composer packages
29 | id: composer-cache
30 | uses: actions/cache@v3
31 | with:
32 | path: vendor
33 | key: ${{ runner.os }}-php-${{ matrix.php-versions }}-${{ hashFiles('**/composer.lock') }}
34 | restore-keys: |
35 | ${{ runner.os }}-php-${{ matrix.php-versions }}-
36 |
37 | - name: Install dependencies
38 | run: composer install --prefer-dist --no-progress --no-suggest
39 |
40 | - name: Create build directories
41 | run: mkdir -p build/logs build/coverage
42 |
43 | - name: Run PHPUnit tests
44 | run: vendor/bin/phpunit --coverage-clover build/logs/clover.xml --coverage-html build/coverage
45 |
46 | - if: |
47 | matrix.php-versions == '8.1' &&
48 | matrix.operating-system == 'ubuntu-latest'
49 | name: Check test coverage
50 | uses: johanvanhelden/gha-clover-test-coverage-check@v1
51 | with:
52 | percentage: "60"
53 | filename: "build/logs/clover.xml"
54 |
55 | - if: |
56 | matrix.php-versions == '8.1' &&
57 | matrix.operating-system == 'ubuntu-latest'
58 | name: Upload coverage reports
59 | uses: actions/upload-artifact@v4
60 | with:
61 | name: coverage-report
62 | path: build/coverage/
63 | retention-days: 30
64 |
65 |
--------------------------------------------------------------------------------
/.github/workflows/coverage-pages.yml:
--------------------------------------------------------------------------------
1 | name: Deploy Coverage to GitHub Pages
2 |
3 | on:
4 | workflow_run:
5 | workflows: ["PHPUnit"]
6 | types:
7 | - completed
8 | branches: [main, master]
9 |
10 | permissions:
11 | contents: read
12 | pages: write
13 | id-token: write
14 |
15 | concurrency:
16 | group: "pages"
17 | cancel-in-progress: false
18 |
19 | jobs:
20 | deploy:
21 | if: ${{ github.event.workflow_run.conclusion == 'success' }}
22 | environment:
23 | name: github-pages
24 | url: ${{ steps.deployment.outputs.page_url }}
25 | runs-on: ubuntu-latest
26 | steps:
27 | - name: Setup Pages
28 | uses: actions/configure-pages@v4
29 |
30 | - name: Download coverage artifact
31 | uses: actions/download-artifact@v4
32 | with:
33 | name: coverage-report
34 | path: ./coverage
35 | run-id: ${{ github.event.workflow_run.id }}
36 | github-token: ${{ secrets.GITHUB_TOKEN }}
37 |
38 | - name: Create index page
39 | run: |
40 | cat > ./coverage/README.md << 'EOF'
41 | # Code Coverage Report
42 |
43 | This page contains the code coverage report for the project.
44 |
45 | - [Coverage Report](./index.html) - Interactive HTML coverage report
46 | - Generated from commit: `${{ github.event.workflow_run.head_sha }}`
47 | - Branch: `${{ github.event.workflow_run.head_branch }}`
48 | - Workflow run: [${{ github.event.workflow_run.id }}](${{ github.event.workflow_run.html_url }})
49 |
50 | ## How to read the coverage report
51 |
52 | - **Green lines**: Covered by tests
53 | - **Red lines**: Not covered by tests
54 | - **Yellow lines**: Partially covered
55 |
56 | Click on any file in the coverage report to see line-by-line coverage details.
57 | EOF
58 |
59 | - name: Upload artifact
60 | uses: actions/upload-pages-artifact@v3
61 | with:
62 | path: ./coverage
63 |
64 | - name: Deploy to GitHub Pages
65 | id: deployment
66 | uses: actions/deploy-pages@v4
--------------------------------------------------------------------------------
/CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Code of Conduct
2 |
3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
4 |
5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality.
6 |
7 | Examples of unacceptable behavior by participants include:
8 |
9 | * The use of sexualized language or imagery
10 | * Personal attacks
11 | * Trolling or insulting/derogatory comments
12 | * Public or private harassment
13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission
14 | * Other unethical or unprofessional conduct.
15 |
16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team.
17 |
18 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community in a direct capacity. Personal views, beliefs and values of individuals do not necessarily reflect those of the organisation or affiliated individuals and organisations.
19 |
20 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
21 |
22 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/)
23 |
--------------------------------------------------------------------------------
/src/Shutdown/PredisShutdown.php:
--------------------------------------------------------------------------------
1 | redis = $redis;
23 | $this->key = $key;
24 | }
25 |
26 | /**
27 | * {@inheritdoc}
28 | *
29 | * Returns true:
30 | *
31 | * - if shutdown timestamp is set,
32 | * - and timestamp is not in future,
33 | * - and hermes was started ($startTime) before timestamp
34 | */
35 | public function shouldShutdown(DateTime $startTime): bool
36 | {
37 | // load UNIX timestamp from redis
38 | $shutdownTime = $this->redis->get($this->key);
39 | if ($shutdownTime === null) {
40 | return false;
41 | }
42 | $shutdownTime = (int) $shutdownTime;
43 |
44 | // do not shutdown if shutdown time is in future
45 | if ($shutdownTime > time()) {
46 | return false;
47 | }
48 |
49 | // do not shutdown if hermes started after shutdown time
50 | if ($shutdownTime < $startTime->getTimestamp()) {
51 | return false;
52 | }
53 |
54 | return true;
55 | }
56 |
57 | /**
58 | * {@inheritdoc}
59 | *
60 | * Sets to Redis value `$shutdownTime` (or current DateTime) to `$key` defined in constructor.
61 | */
62 | public function shutdown(?DateTime $shutdownTime = null): bool
63 | {
64 | if ($shutdownTime === null) {
65 | $shutdownTime = new DateTime();
66 | }
67 |
68 | /** @var \Predis\Response\Status $response */
69 | $response = $this->redis->set($this->key, $shutdownTime->format('U'));
70 |
71 | return $response->getPayload() === 'OK';
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/Emitter.php:
--------------------------------------------------------------------------------
1 | driver = $driver;
25 | $this->logger = $logger;
26 | }
27 |
28 | /**
29 | * {@inheritdoc}
30 | *
31 | * @throws Driver\UnknownPriorityException
32 | */
33 | public function emit(MessageInterface $message, int $priority = Dispatcher::DEFAULT_PRIORITY): EmitterInterface
34 | {
35 | $this->driver->send($message, $priority);
36 |
37 | $this->log(
38 | LogLevel::INFO,
39 | "Dispatcher send message #{$message->getId()} to driver " . get_class($this->driver),
40 | $this->messageLoggerContext($message)
41 | );
42 | return $this;
43 | }
44 |
45 | /**
46 | * Serialize message to logger context
47 | *
48 | * @param MessageInterface $message
49 | *
50 | * @return array
51 | */
52 | private function messageLoggerContext(MessageInterface $message): array
53 | {
54 | return [
55 | 'id' => $message->getId(),
56 | 'created' => $message->getCreated(),
57 | 'type' => $message->getType(),
58 | 'payload' => $message->getPayload(),
59 | ];
60 | }
61 |
62 | /**
63 | * Internal log method wrapper
64 | *
65 | * @param mixed $level
66 | * @param string $message
67 | * @param array $context
68 | *
69 | * @return void
70 | */
71 | private function log($level, string $message, array $context = []): void
72 | {
73 | if ($this->logger !== null) {
74 | $this->logger->log($level, $message, $context);
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/Driver/DriverInterface.php:
--------------------------------------------------------------------------------
1 | [
15 | 'id' => $message->getId(),
16 | 'type' => $message->getType(),
17 | 'created' => $message->getCreated(),
18 | 'payload' => $message->getPayload(),
19 | 'execute_at' => $message->getExecuteAt(),
20 | 'retries' => $message->getRetries(),
21 | ]
22 | ], JSON_INVALID_UTF8_IGNORE);
23 | if ($result === false) {
24 | throw new SerializeException("Cannot serialize message {$message->getId()}");
25 | }
26 | return $result;
27 | }
28 |
29 | /**
30 | * {@inheritdoc}
31 | */
32 | public function unserialize(string $string): MessageInterface
33 | {
34 | $data = json_decode($string, true);
35 | if (!is_array($data) || !isset($data['message'])) {
36 | throw new SerializeException("Cannot unserialize message from '{$string}'");
37 | }
38 | $message = $data['message'];
39 | if (!is_array($message) || !isset($message['type'], $message['id'], $message['created'])) {
40 | throw new SerializeException("Invalid message format in '{$string}'");
41 | }
42 |
43 | $executeAt = null;
44 | if (isset($message['execute_at']) && is_numeric($message['execute_at'])) {
45 | $executeAt = (float) $message['execute_at'];
46 | }
47 |
48 | $retries = 0;
49 | if (isset($message['retries']) && is_numeric($message['retries'])) {
50 | $retries = (int) $message['retries'];
51 | }
52 |
53 | $payload = null;
54 | if (isset($message['payload']) && is_array($message['payload'])) {
55 | $payload = $message['payload'];
56 | }
57 |
58 | return new Message($message['type'], $payload, $message['id'], (float) $message['created'], $executeAt, $retries);
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Message.php:
--------------------------------------------------------------------------------
1 | |null
14 | */
15 | private ?array $payload;
16 |
17 | private string $messageId;
18 |
19 | private float $created;
20 |
21 | private ?float $executeAt;
22 |
23 | private int $retries;
24 |
25 | /**
26 | * Native implementation of message.
27 | *
28 | * @param string $type
29 | * @param array|null $payload
30 | * @param string|null $messageId
31 | * @param float|null $created timestamp (microtime(true))
32 | * @param float|null $executeAt timestamp (microtime(true))
33 | * @param int $retries
34 | */
35 | public function __construct(string $type, ?array $payload = null, ?string $messageId = null, ?float $created = null, ?float $executeAt = null, int $retries = 0)
36 | {
37 | $this->messageId = ($messageId === null || $messageId === '')
38 | ? Uuid::uuid4()->toString()
39 | : $messageId;
40 |
41 | $this->created = $created ?? microtime(true);
42 |
43 | $this->type = $type;
44 | $this->payload = $payload;
45 | $this->executeAt = $executeAt;
46 | $this->retries = $retries;
47 | }
48 |
49 | /**
50 | * {@inheritdoc}
51 | */
52 | public function getId(): string
53 | {
54 | return $this->messageId;
55 | }
56 |
57 | /**
58 | * {@inheritdoc}
59 | */
60 | public function getCreated(): float
61 | {
62 | return $this->created;
63 | }
64 |
65 | /**
66 | * {@inheritdoc}
67 | */
68 | public function getExecuteAt(): ?float
69 | {
70 | return $this->executeAt;
71 | }
72 |
73 | /**
74 | * {@inheritdoc}
75 | */
76 | public function getType(): string
77 | {
78 | return $this->type;
79 | }
80 |
81 | /**
82 | * {@inheritdoc}
83 | */
84 | public function getPayload(): ?array
85 | {
86 | return $this->payload;
87 | }
88 |
89 | /**
90 | * {@inheritdoc}
91 | */
92 | public function getRetries(): int
93 | {
94 | return $this->retries;
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/Driver/AmazonSqsDriver.php:
--------------------------------------------------------------------------------
1 |
45 | * use Aws\Sqs\SqsClient;
46 | *
47 | * $client = SqsClient::factory(array(
48 | * 'profile' => '',
49 | * 'region' => ''
50 | * ));
51 | *
52 | *
53 | * or
54 | *
55 | *
56 | * use Aws\Common\Aws;
57 | *
58 | * // Create a service builder using a configuration file
59 | * $aws = Aws::factory('/path/to/my_config.json');
60 | *
61 | * // Get the client from the builder by namespace
62 | * $client = $aws->get('Sqs');
63 | *
64 | *
65 | * More examples see: https://docs.aws.amazon.com/aws-sdk-php/v2/guide/service-sqs.html
66 | *
67 | *
68 | * @see examples/sqs folder
69 | *
70 | * @param SqsClient $client
71 | * @param string $queueName
72 | * @param array $queueAttributes
73 | */
74 | public function __construct(SqsClient $client, string $queueName, array $queueAttributes = [])
75 | {
76 | $this->client = $client;
77 | $this->queueName = $queueName;
78 | $this->serializer = new MessageSerializer();
79 |
80 | $result = $client->createQueue([
81 | 'QueueName' => $queueName,
82 | 'Attributes' => $queueAttributes,
83 | ]);
84 |
85 | $this->queueUrl = $result->get('QueueUrl');
86 | }
87 |
88 | /**
89 | * {@inheritdoc}
90 | */
91 | public function send(MessageInterface $message, int $priority = Dispatcher::DEFAULT_PRIORITY): bool
92 | {
93 | $this->client->sendMessage([
94 | 'QueueUrl' => $this->queueUrl,
95 | 'MessageBody' => $this->serializer->serialize($message),
96 | ]);
97 | return true;
98 | }
99 |
100 | /**
101 | * @param string $name
102 | * @param int $priority
103 | *
104 | * @throws NotSupportedException
105 | */
106 | public function setupPriorityQueue(string $name, int $priority): void
107 | {
108 | throw new NotSupportedException("AmazonSQS is not supporting priority queues now");
109 | }
110 |
111 | /**
112 | * {@inheritdoc}
113 | */
114 | public function wait(Closure $callback, array $priorities = []): void
115 | {
116 | while (true) {
117 | $this->checkShutdown();
118 | if (!$this->shouldProcessNext()) {
119 | break;
120 | }
121 |
122 | $result = $this->client->receiveMessage([
123 | 'QueueUrl' => $this->queueUrl,
124 | 'WaitTimeSeconds' => 1,
125 | ]);
126 |
127 | $messages = $result['Messages'];
128 |
129 | if ($messages) {
130 | $hermesMessages = [];
131 | foreach ($messages as $message) {
132 | $this->client->deleteMessage([
133 | 'QueueUrl' => $this->queueUrl,
134 | 'ReceiptHandle' => $message['ReceiptHandle'],
135 | ]);
136 | $hermesMessages[] = $this->serializer->unserialize($message['Body']);
137 | }
138 | foreach ($hermesMessages as $hermesMessage) {
139 | $callback($hermesMessage);
140 | $this->incrementProcessedItems();
141 | }
142 | } else {
143 | if ($this->sleepInterval) {
144 | $this->checkShutdown();
145 | sleep($this->sleepInterval);
146 | }
147 | }
148 | }
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/Driver/LazyRabbitMqDriver.php:
--------------------------------------------------------------------------------
1 | */
31 | private $amqpMessageProperties = [];
32 |
33 | /** @var integer */
34 | private $refreshInterval;
35 |
36 | /** @var string */
37 | private $consumerTag;
38 |
39 | /**
40 | * @param AMQPLazyConnection $connection
41 | * @param string $queue
42 | * @param array $amqpMessageProperties
43 | * @param int $refreshInterval
44 | * @param string $consumerTag
45 | */
46 | public function __construct(AMQPLazyConnection $connection, string $queue, array $amqpMessageProperties = [], int $refreshInterval = 0, string $consumerTag = 'hermes')
47 | {
48 | $this->connection = $connection;
49 | $this->queue = $queue;
50 | $this->amqpMessageProperties = $amqpMessageProperties;
51 | $this->refreshInterval = $refreshInterval;
52 | $this->consumerTag = $consumerTag;
53 | $this->serializer = new MessageSerializer();
54 | }
55 |
56 | /**
57 | * {@inheritdoc}
58 | *
59 | * @throws \PhpAmqpLib\Exception\AMQPChannelClosedException
60 | * @throws \PhpAmqpLib\Exception\AMQPConnectionBlockedException
61 | * @throws \PhpAmqpLib\Exception\AMQPConnectionClosedException
62 | * @throws \PhpAmqpLib\Exception\AMQPTimeoutException
63 | */
64 | public function send(MessageInterface $message, int $priority = Dispatcher::DEFAULT_PRIORITY): bool
65 | {
66 | $rabbitMessage = new AMQPMessage($this->serializer->serialize($message), $this->amqpMessageProperties);
67 | $this->getChannel()->basic_publish($rabbitMessage, '', $this->queue);
68 | return true;
69 | }
70 |
71 | /**
72 | * @param string $name
73 | * @param int $priority
74 | *
75 | * @throws NotSupportedException
76 | */
77 | public function setupPriorityQueue(string $name, int $priority): void
78 | {
79 | throw new NotSupportedException("LazyRabbitMqDriver is not supporting priority queues now");
80 | }
81 |
82 | /**
83 | * {@inheritdoc}
84 | *
85 | * @throws ShutdownException
86 | * @throws \PhpAmqpLib\Exception\AMQPOutOfBoundsException
87 | * @throws \PhpAmqpLib\Exception\AMQPRuntimeException
88 | * @throws \PhpAmqpLib\Exception\AMQPTimeoutException
89 | * @throws \ErrorException
90 | */
91 | public function wait(Closure $callback, array $priorities = []): void
92 | {
93 | while (true) {
94 | $this->getChannel()->basic_consume(
95 | $this->queue,
96 | $this->consumerTag,
97 | false,
98 | true,
99 | false,
100 | false,
101 | function ($rabbitMessage) use ($callback) {
102 | $message = $this->serializer->unserialize($rabbitMessage->body);
103 | $callback($message);
104 | }
105 | );
106 |
107 | while (count($this->getChannel()->callbacks)) {
108 | $this->getChannel()->wait(null, true);
109 | $this->checkShutdown();
110 | if (!$this->shouldProcessNext()) {
111 | break 2;
112 | }
113 | if ($this->refreshInterval) {
114 | sleep($this->refreshInterval);
115 | }
116 | }
117 | }
118 |
119 | $this->getChannel()->close();
120 | $this->connection->close();
121 | }
122 |
123 | /**
124 | * @throws \PhpAmqpLib\Exception\AMQPTimeoutException
125 | * @return AMQPChannel
126 | */
127 | private function getChannel(): AMQPChannel
128 | {
129 | if ($this->channel !== null) {
130 | return $this->channel;
131 | }
132 | $this->channel = $this->connection->channel();
133 | $this->channel->queue_declare($this->queue, false, false, false, false);
134 | return $this->channel;
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/src/Driver/PredisSetDriver.php:
--------------------------------------------------------------------------------
1 | */
21 | private $queues = [];
22 |
23 | /**
24 | * @var string
25 | */
26 | private $scheduleKey;
27 |
28 | /**
29 | * @var Client
30 | */
31 | private $redis;
32 |
33 | /**
34 | * @var integer
35 | */
36 | private $refreshInterval;
37 |
38 | /**
39 | * Create new PredisSetDriver
40 | *
41 | * This driver is using redis set. With send message it add new item to set
42 | * and in wait() command it is reading new items in this set.
43 | * This driver doesn't use redis pubsub functionality, only redis sets.
44 | *
45 | * Managing connection to redis is up to you and you have to create it outsite
46 | * of this class. You will need to install predis php package.
47 | *
48 | * @param Client $redis
49 | * @param string $key
50 | * @param integer $refreshInterval
51 | * @param string $scheduleKey
52 | * @see examples/redis
53 | *
54 | * @throws NotSupportedException
55 | */
56 | public function __construct(Client $redis, string $key = 'hermes', int $refreshInterval = 1, string $scheduleKey = 'hermes_schedule')
57 | {
58 | $this->setupPriorityQueue($key, Dispatcher::DEFAULT_PRIORITY);
59 |
60 | $this->scheduleKey = $scheduleKey;
61 | $this->redis = $redis;
62 | $this->refreshInterval = $refreshInterval;
63 | $this->serializer = new MessageSerializer();
64 | }
65 |
66 | /**
67 | * {@inheritdoc}
68 | *
69 | * @throws UnknownPriorityException
70 | * @throws SerializeException
71 | */
72 | public function send(MessageInterface $message, int $priority = Dispatcher::DEFAULT_PRIORITY): bool
73 | {
74 | if ($message->getExecuteAt() && $message->getExecuteAt() > microtime(true)) {
75 | $this->redis->zadd($this->scheduleKey, [$this->serializer->serialize($message) => $message->getExecuteAt()]);
76 | } else {
77 | $key = $this->getKey($priority);
78 | $this->redis->sadd($key, [$this->serializer->serialize($message)]);
79 | }
80 | return true;
81 | }
82 |
83 | /**
84 | * {@inheritdoc}
85 | */
86 | public function setupPriorityQueue(string $name, int $priority): void
87 | {
88 | $this->queues[$priority] = $name;
89 | }
90 |
91 | /**
92 | * @param int $priority
93 | * @return string
94 | *
95 | * @throws UnknownPriorityException
96 | */
97 | private function getKey(int $priority): string
98 | {
99 | if (!isset($this->queues[$priority])) {
100 | throw new UnknownPriorityException("Unknown priority {$priority}");
101 | }
102 | return $this->queues[$priority];
103 | }
104 |
105 | /**s
106 | * {@inheritdoc}
107 | *
108 | * @throws ShutdownException
109 | * @throws UnknownPriorityException
110 | * @throws SerializeException
111 | */
112 | public function wait(Closure $callback, array $priorities = []): void
113 | {
114 | $queues = array_reverse($this->queues, true);
115 | while (true) {
116 | $this->checkShutdown();
117 | if (!$this->shouldProcessNext()) {
118 | break;
119 | }
120 |
121 | // check schedule
122 | $messagesString = $this->redis->zrangebyscore($this->scheduleKey, '-inf', microtime(true), ['LIMIT' => ['OFFSET' => 0, 'COUNT' => 1]]);
123 | if (count($messagesString)) {
124 | foreach ($messagesString as $messageString) {
125 | $this->redis->zrem($this->scheduleKey, $messageString);
126 | $this->send($this->serializer->unserialize($messageString));
127 | }
128 | }
129 |
130 | $messageString = null;
131 | $foundPriority = null;
132 |
133 | foreach ($queues as $priority => $name) {
134 | if (count($priorities) > 0 && !in_array($priority, $priorities)) {
135 | continue;
136 | }
137 | if ($messageString !== null) {
138 | break;
139 | }
140 |
141 | $key = $this->getKey($priority);
142 |
143 | $messageString = $this->redis->spop($key);
144 | $foundPriority = $priority;
145 | }
146 |
147 | if ($messageString !== null) {
148 | $message = $this->serializer->unserialize($messageString);
149 | $callback($message, $foundPriority);
150 | $this->incrementProcessedItems();
151 | } else {
152 | if ($this->refreshInterval) {
153 | $this->checkShutdown();
154 | sleep($this->refreshInterval);
155 | }
156 | }
157 | }
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/src/Driver/RedisSetDriver.php:
--------------------------------------------------------------------------------
1 | */
21 | private $queues = [];
22 |
23 | /**
24 | * @var string
25 | */
26 | private $scheduleKey;
27 |
28 | /**
29 | * @var Redis
30 | */
31 | private $redis;
32 |
33 | /**
34 | * @var integer
35 | */
36 | private $refreshInterval;
37 |
38 | /**
39 | * Create new RedisSetDriver
40 | *
41 | * This driver is using redis set. With send message it add new item to set
42 | * and in wait() command it is reading new items in this set.
43 | * This driver doesn't use redis pubsub functionality, only redis sets.
44 | *
45 | * Managing connection to redis is up to you and you have to create it outsite
46 | * of this class. You have to have enabled native Redis php extension.
47 | *
48 | * @see examples/redis
49 | *
50 | * @param Redis $redis
51 | * @param string $key
52 | * @param integer $refreshInterval
53 | * @param string $scheduleKey
54 | */
55 | public function __construct(Redis $redis, string $key = 'hermes', int $refreshInterval = 1, string $scheduleKey = 'hermes_schedule')
56 | {
57 | $this->setupPriorityQueue($key, Dispatcher::DEFAULT_PRIORITY);
58 |
59 | $this->scheduleKey = $scheduleKey;
60 | $this->redis = $redis;
61 | $this->refreshInterval = $refreshInterval;
62 | $this->serializer = new MessageSerializer();
63 | }
64 |
65 | /**
66 | * {@inheritdoc}
67 | *
68 | * @throws UnknownPriorityException
69 | */
70 | public function send(MessageInterface $message, int $priority = Dispatcher::DEFAULT_PRIORITY): bool
71 | {
72 | if ($message->getExecuteAt() !== null && $message->getExecuteAt() > microtime(true)) {
73 | $this->redis->zAdd($this->scheduleKey, $message->getExecuteAt(), $this->serializer->serialize($message));
74 | } else {
75 | $key = $this->getKey($priority);
76 | $this->redis->sAdd($key, $this->serializer->serialize($message));
77 | }
78 | return true;
79 | }
80 |
81 | /**
82 | * {@inheritdoc}
83 | */
84 | public function setupPriorityQueue(string $name, int $priority): void
85 | {
86 | $this->queues[$priority] = $name;
87 | }
88 |
89 | /**
90 | * @param int $priority
91 | * @return string
92 | *
93 | * @throws UnknownPriorityException
94 | */
95 | private function getKey(int $priority): string
96 | {
97 | if (!isset($this->queues[$priority])) {
98 | throw new UnknownPriorityException("Unknown priority {$priority}");
99 | }
100 | return $this->queues[$priority];
101 | }
102 |
103 | /**
104 | * {@inheritdoc}
105 | *
106 | * @throws ShutdownException
107 | * @throws UnknownPriorityException
108 | * @throws SerializeException
109 | */
110 | public function wait(Closure $callback, array $priorities = []): void
111 | {
112 | $queues = array_reverse($this->queues, true);
113 | while (true) {
114 | $this->checkShutdown();
115 | if (!$this->shouldProcessNext()) {
116 | break;
117 | }
118 |
119 | // check schedule
120 | $microTime = microtime(true);
121 | $messageStrings = $this->redis->zRangeByScore($this->scheduleKey, '-inf', (string) $microTime, ['limit' => [0, 1]]);
122 | for ($i = 1; $i <= count($messageStrings); $i++) {
123 | $messageString = $this->pop($this->scheduleKey);
124 | if (!$messageString) {
125 | break;
126 | }
127 | $scheduledMessage = $this->serializer->unserialize($messageString);
128 | $this->send($scheduledMessage);
129 |
130 | if ($scheduledMessage->getExecuteAt() > $microTime) {
131 | break;
132 | }
133 | }
134 |
135 | $messageString = null;
136 | $foundPriority = null;
137 |
138 | foreach ($queues as $priority => $name) {
139 | if (count($priorities) > 0 && !in_array($priority, $priorities)) {
140 | continue;
141 | }
142 | if ($messageString !== null) {
143 | break;
144 | }
145 |
146 | $messageString = $this->pop($this->getKey($priority));
147 | $foundPriority = $priority;
148 | }
149 |
150 | if ($messageString !== null) {
151 | $message = $this->serializer->unserialize($messageString);
152 | $callback($message, $foundPriority);
153 | $this->incrementProcessedItems();
154 | } else {
155 | if ($this->refreshInterval) {
156 | $this->checkShutdown();
157 | sleep($this->refreshInterval);
158 | }
159 | }
160 | }
161 | }
162 |
163 | private function pop(string $key): ?string
164 | {
165 | $messageString = $this->redis->sPop($key);
166 | if (is_string($messageString) && $messageString !== "") {
167 | return $messageString;
168 | }
169 |
170 | return null;
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 | All notable changes to this project will be documented in this file.
3 | Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles.
4 |
5 | ## [Unreleased][unreleased]
6 |
7 | ### Added
8 |
9 | * Enhanced PHPStan configuration with strict rules and additional type checking
10 | * Added `phpstan/phpstan-strict-rules` extension for improved code quality analysis
11 | * Improved type safety with PHP 7.4+ typed properties in core classes
12 | * Added comprehensive GitHub Actions workflows with PHP 7.4+ support
13 | * Added security audit workflow with weekly vulnerability scanning
14 | * Added composer validation and dependency caching in CI
15 | * Added local code coverage reporting with HTML and Clover XML output
16 | * Added GitHub Pages deployment for coverage reports
17 | * Added `coverage.sh` helper script for generating coverage reports
18 | * Added Makefile with coverage target
19 |
20 | ### Changed
21 |
22 | * **BREAKING CHANGE**: Updated minimum PHP version requirement to PHP 7.4+ with full PHP 8.0+ support
23 | * **BREAKING CHANGE**: Removed PHP 7.2 and 7.3 support from CI/CD workflows
24 | * Enhanced `Message` class with strict typing and improved type annotations
25 | * Enhanced `Dispatcher` class with typed properties and better type safety
26 | * Enhanced `MessageSerializer` class with stricter validation and error handling
27 | * Improved PHPStan analysis with strict comparison checks and function type hints
28 | * Updated composer dependencies to support PHP 8.0+ while maintaining backward compatibility
29 | * Modernized codebase with PHP 7.4+ features (typed properties, arrow functions, null coalescing)
30 | * Updated GitHub Actions to latest versions for better security and performance
31 | * Expanded CI test matrix to include PHP 8.1, 8.2, and 8.3
32 | * Updated .gitignore to exclude log files and cache directory
33 | * Removed Scrutinizer Code Coverage integration in favor of local reporting
34 | * Enabled Xdebug coverage mode in PHPUnit workflow for accurate coverage metrics
35 | * Updated test coverage check to use `johanvanhelden/gha-clover-test-coverage-check@v1`
36 | * Coverage reports are now automatically published to GitHub Pages after successful test runs
37 |
38 | ### Fixed
39 |
40 | * Fixed strict comparison issues in `Message` constructor
41 | * Fixed boolean condition checks for better PHP 8+ compatibility
42 | * Added proper return type hints to anonymous functions
43 | * Improved JSON serialization error handling in `MessageSerializer`
44 | * Removed implicitly nullable parameters for better type safety
45 |
46 |
47 | ## 4.2.0 - 2024-04-29
48 |
49 | ### Added
50 |
51 | * **BREAKING CHANGE**: changed interface for Dispatcher - added ability to unregister handlers
52 |
53 |
54 | ## 4.1.0 - 2023-12-17
55 |
56 | ### Changed
57 |
58 | - RedisSetDriver - add atomicity to scheduled set
59 |
60 |
61 | ## 4.0.1 - 2021-11-23
62 |
63 | ### Changed
64 |
65 | * Fixed Predis driver when retry is being scheduled [#48](https://github.com/tomaj/hermes/issues/48)
66 |
67 |
68 | ## 4.0.0 - 2021-02-02
69 |
70 | ### Changed
71 |
72 | * **BREAKING CHANGE**: Renamed all **Restart** to **Shutdown**
73 | * **BREAKING CHANGE**: Removed deprecated *RabbitMqDriver**. You can use LazyRabbitMqDriver
74 | * **BREAKING CHANGE**: Splitted **RedisSetDriver** to two implementations based on how it is interacting with redis. For using Redis php extension you can use old **RedisSetDriver**, for using predis package you have to use **PredisSetDriver**
75 | * Added clearstatcache() into SharedFileShutdown
76 |
77 |
78 |
79 | ## 3.1.0 - 2020-10-22
80 |
81 | ### Changed
82 |
83 | * Added support for *soft restart* to all drivers
84 | * Added `consumer tag` to LazyRabbitMq driver for consumer
85 | * Added support for _max items_ and _restart_ for LazyRabbitMq Driver
86 | * updated restart policy for AmazonSQS Driver
87 |
88 |
89 | ## 3.0.1 - 2020-10-16
90 |
91 | ### Changed
92 |
93 | * Fixed `RedisRestart::restart()` response for Predis instance. `\Predis\Client::set()` returns object _(with 'OK' payload)_ instead of bool.
94 | * Deprecated `RabbitMqDriver` (will be removed in 4.0.0) - use `LazyRabbitMqDriver` instead
95 | * Fixed error while parsing message with invalid UTF8 character
96 |
97 | ## 3.0.0 - 2020-10-13
98 |
99 | ### Added
100 |
101 | * `RedisRestart` - implementation of `RestartInterface` allowing graceful shutdown of Hermes through Redis entry.
102 | * **BREAKING CHANGE**: Added `RestartInterface::restart()` method to initiate Hermes restart without knowing the requirements of used `RestartInterface` implementation. _Updated all related tests._
103 | * **BREAKING CHANGE**: Removed support for ZeroMQ - driver moved into [separated package](https://github.com/tomaj/hermes-zmq-driver)
104 | * Upgraded phpunit and tests
105 | * **BREAKING CHANGE** Drop support for php 7.1
106 |
107 | ## 2.2.0 - 2019-07-12
108 |
109 | ### Added
110 |
111 | * Ability to register multiple handlers at once for one key (`registerHandlers` in `DispatcherInterface`)
112 | * Fixed loss of messages when the handler crashes and mechanism of retries for RabbitMQ Drivers
113 |
114 | ## 2.1.0 - 2019-07-06
115 |
116 | ### Added
117 |
118 | * Added retry to handlers
119 |
120 | #### Added
121 |
122 | * Added missing handle() method to DispatcherInterface
123 |
124 | ## 2.0.0 - 2018-08-14
125 |
126 | ### Added
127 |
128 | * Message now support scheduled parameter - Driver needs to support this behaviour.
129 | * Type hints
130 |
131 | ### Changed
132 |
133 | * Dropped support for php 5.4
134 | * Deprecated emit() in Disapatcher - introduced Emitter
135 |
136 | ## 1.2.0 - 2016-09-26
137 |
138 | ### Updated
139 |
140 | * Amazon aws library updated to version 3 in composer - still works with v2 but you have to initialize Sqs client in v2 style
141 |
142 | ## 1.1.0 - 2016-09-05
143 |
144 | ### Added
145 |
146 | * Amazon SQS driver
147 |
148 | ## 1.0.0 - 2016-09-02
149 |
150 | ### Added
151 |
152 | * First stable version
153 | * Added ACK to rabbitmq driver
154 |
155 | ## 0.4.0 - 2016-04-26
156 |
157 | ### Added
158 |
159 | * Added RabbitMQ Lazy driver
160 |
161 | ## 0.3.0 - 2016-03-23
162 |
163 | ### Added
164 |
165 | * Added possibility to gracefull restart worker with RestartInterface
166 | * Added Tracy debugger log when error occured
167 |
168 | ## 0.2.0 - 2015-10-30
169 |
170 | ### Changed
171 |
172 | * Handling responses from handlers.
173 | * Tests structure refactored
174 |
175 | ## 0.1.0 - 2015-10-28
176 |
177 | ### Added
178 |
179 | * initial version with 2 drivers
180 |
--------------------------------------------------------------------------------
/src/Dispatcher.php:
--------------------------------------------------------------------------------
1 | driver = $driver;
45 | $this->logger = $logger;
46 | $this->shutdown = $shutdown;
47 | $this->startTime = new DateTime();
48 |
49 | // check if driver use ShutdownTrait
50 | if ($shutdown !== null && method_exists($this->driver, 'setShutdown')) {
51 | $this->driver->setShutdown($shutdown);
52 | }
53 | }
54 |
55 | /**
56 | * @param MessageInterface $message
57 | * @param int $priority
58 | * @return DispatcherInterface
59 | * @deprecated - use Emitter::emit method instead
60 | */
61 | public function emit(MessageInterface $message, int $priority = self::DEFAULT_PRIORITY): DispatcherInterface
62 | {
63 | $this->driver->send($message, $priority);
64 |
65 | $this->log(
66 | LogLevel::INFO,
67 | "Dispatcher send message #{$message->getId()} with priority {$priority} to driver " . get_class($this->driver),
68 | $this->messageLoggerContext($message)
69 | );
70 | return $this;
71 | }
72 |
73 | /**
74 | * Basic method for background job to star listening.
75 | *
76 | * This method hook to driver wait() method and start listening events.
77 | * Method is blocking, so when you call it all processing will stop.
78 | * WARNING! Don't use it on web server calls. Run it only with cli.
79 | *
80 | * @param int[] $priorities
81 | *
82 | * @return void
83 | */
84 | public function handle(array $priorities = []): void
85 | {
86 | try {
87 | $this->driver->wait(function (MessageInterface $message, int $priority = Dispatcher::DEFAULT_PRIORITY): bool {
88 | $this->log(
89 | LogLevel::INFO,
90 | "Start handle message #{$message->getId()} ({$message->getType()}) priority:{$priority}",
91 | $this->messageLoggerContext($message)
92 | );
93 |
94 | $result = $this->dispatch($message);
95 |
96 | if ($this->shutdown !== null && $this->shutdown->shouldShutdown($this->startTime)) {
97 | throw new ShutdownException('Shutdown');
98 | }
99 |
100 | return $result;
101 | }, $priorities);
102 | } catch (ShutdownException $e) {
103 | $this->log(LogLevel::NOTICE, 'Exiting hermes dispatcher - shutdown');
104 | } catch (Exception $exception) {
105 | if (Debugger::isEnabled()) {
106 | Debugger::log($exception, Debugger::EXCEPTION);
107 | }
108 | }
109 | }
110 |
111 | /**
112 | * Dispatch message
113 | *
114 | * @param MessageInterface $message
115 | *
116 | * @return bool
117 | */
118 | private function dispatch(MessageInterface $message): bool
119 | {
120 | $type = $message->getType();
121 |
122 | if (!$this->hasHandlers($type)) {
123 | return true;
124 | }
125 |
126 | $result = true;
127 |
128 | foreach ($this->handlers[$type] as $handler) {
129 | $handlerResult = $this->handleMessage($handler, $message);
130 |
131 | if ($result && !$handlerResult) {
132 | $result = false;
133 | }
134 | }
135 |
136 | return $result;
137 | }
138 |
139 | /**
140 | * Handle given message with given handler
141 | *
142 | * @param HandlerInterface $handler
143 | * @param MessageInterface $message
144 | *
145 | * @return bool
146 | */
147 | private function handleMessage(HandlerInterface $handler, MessageInterface $message): bool
148 | {
149 | // check if handler implements Psr\Log\LoggerAwareInterface (you can use \Psr\Log\LoggerAwareTrait)
150 | if ($this->logger !== null && method_exists($handler, 'setLogger')) {
151 | $handler->setLogger($this->logger);
152 | }
153 |
154 | try {
155 | $result = $handler->handle($message);
156 |
157 | $this->log(
158 | LogLevel::INFO,
159 | "End handle message #{$message->getId()} ({$message->getType()})",
160 | $this->messageLoggerContext($message)
161 | );
162 | } catch (Exception $e) {
163 | $this->log(
164 | LogLevel::ERROR,
165 | "Handler " . get_class($handler) . " throws exception - {$e->getMessage()}",
166 | ['error' => $e, 'message' => $this->messageLoggerContext($message), 'exception' => $e]
167 | );
168 | if (Debugger::isEnabled()) {
169 | Debugger::log($e, Debugger::EXCEPTION);
170 | }
171 |
172 | $this->retryMessage($message, $handler);
173 |
174 | $result = false;
175 | }
176 | return $result;
177 | }
178 |
179 | /**
180 | * Helper function for sending retrying message back to driver
181 | *
182 | * @param MessageInterface $message
183 | * @param HandlerInterface $handler
184 | */
185 | private function retryMessage(MessageInterface $message, HandlerInterface $handler): void
186 | {
187 | if (method_exists($handler, 'canRetry') && method_exists($handler, 'maxRetry')) {
188 | if ($message->getRetries() < $handler->maxRetry()) {
189 | $executeAt = $this->nextRetry($message);
190 | $newMessage = new Message($message->getType(), $message->getPayload(), $message->getId(), $message->getCreated(), $executeAt, $message->getRetries() + 1);
191 | $this->driver->send($newMessage);
192 | }
193 | }
194 | }
195 |
196 | /**
197 | * Calculate next retry
198 | *
199 | * Inspired by ruby sidekiq (https://github.com/mperham/sidekiq/wiki/Error-Handling#automatic-job-retry)
200 | *
201 | * @param MessageInterface $message
202 | * @return float
203 | */
204 | private function nextRetry(MessageInterface $message): float
205 | {
206 | return microtime(true) + pow($message->getRetries(), 4) + 15 + (rand(1, 30) * ($message->getRetries() + 1));
207 | }
208 |
209 | /**
210 | * Check if actual dispatcher has handler for given type
211 | *
212 | * @param string $type
213 | *
214 | * @return bool
215 | */
216 | private function hasHandlers(string $type): bool
217 | {
218 | return isset($this->handlers[$type]) && count($this->handlers[$type]) > 0;
219 | }
220 |
221 | /**
222 | * {@inheritdoc}
223 | */
224 | public function registerHandler(string $type, HandlerInterface $handler): DispatcherInterface
225 | {
226 | if (!isset($this->handlers[$type])) {
227 | $this->handlers[$type] = [];
228 | }
229 |
230 | $this->handlers[$type][] = $handler;
231 | return $this;
232 | }
233 |
234 | /**
235 | * {@inheritdoc}
236 | */
237 | public function registerHandlers(string $type, array $handlers): DispatcherInterface
238 | {
239 | foreach ($handlers as $handler) {
240 | $this->registerHandler($type, $handler);
241 | }
242 | return $this;
243 | }
244 |
245 | /**
246 | * {@inheritdoc}
247 | */
248 | public function unregisterAllHandlers(): DispatcherInterface
249 | {
250 | $this->handlers = [];
251 | return $this;
252 | }
253 |
254 | /**
255 | * {@inheritdoc}
256 | */
257 | public function unregisterHandler(string $type, HandlerInterface $handler): DispatcherInterface
258 | {
259 | if (!isset($this->handlers[$type])) {
260 | return $this;
261 | }
262 | $this->handlers[$type] = array_filter(
263 | $this->handlers[$type],
264 | fn(HandlerInterface $registeredHandler): bool => $registeredHandler !== $handler
265 | );
266 |
267 | return $this;
268 | }
269 |
270 | /**
271 | * Serialize message to logger context
272 | *
273 | * @param MessageInterface $message
274 | *
275 | * @return array
276 | */
277 | private function messageLoggerContext(MessageInterface $message): array
278 | {
279 | return [
280 | 'id' => $message->getId(),
281 | 'created' => $message->getCreated(),
282 | 'type' => $message->getType(),
283 | 'payload' => $message->getPayload(),
284 | 'retries' => $message->getRetries(),
285 | 'execute_at' => $message->getExecuteAt(),
286 | ];
287 | }
288 |
289 | /**
290 | * Interal log method wrapper
291 | *
292 | * @param mixed $level
293 | * @param string $message
294 | * @param array $context
295 | *
296 | * @return void
297 | */
298 | private function log($level, string $message, array $context = []): void
299 | {
300 | if ($this->logger !== null) {
301 | $this->logger->log($level, $message, $context);
302 | }
303 | }
304 | }
305 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hermes
2 |
3 | **Background job processing PHP library**
4 |
5 | [](https://packagist.org/packages/tomaj/hermes)
6 | [](https://phpstan.org/)
7 |
8 | ## What is Hermes?
9 |
10 | Hermes is a lightweight PHP library for background job processing. When you need to handle time-consuming tasks outside of HTTP requests—such as sending emails, calling external APIs, or processing data—Hermes provides a clean, efficient solution.
11 |
12 | Key features:
13 | - **Multiple queue backends**: Support for Redis, RabbitMQ, Amazon SQS, and more
14 | - **Simple integration**: Easy to add to existing projects with minimal setup
15 | - **Extensible architecture**: Create custom drivers and handlers for your specific needs
16 | - **Production-ready**: Built-in support for priorities, retries, and graceful shutdown
17 |
18 |
19 | ## Installation
20 |
21 | This library requires PHP 7.4 or later.
22 |
23 | The recommended installation method is via Composer:
24 |
25 | ```bash
26 | $ composer require tomaj/hermes
27 | ```
28 |
29 | Library is compliant with [PSR-1][], [PSR-2][], [PSR-3][] and [PSR-4][].
30 |
31 | [PSR-1]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-1-basic-coding-standard.md
32 | [PSR-2]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md
33 | [PSR-3]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md
34 | [PSR-4]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-4-autoloader.md
35 |
36 |
37 | ## Optional Dependencies
38 |
39 | Hermes supports logging through any PSR-3 compatible logger. For more information, see [psr/log][].
40 |
41 | While the library works without logging, we recommend installing [monolog][] for production environments to track message processing and debugging.
42 |
43 | [psr/log]: https://github.com/php-fig/log
44 | [monolog]: https://github.com/Seldaek/monolog
45 |
46 | ## Supported Drivers
47 |
48 | Hermes includes built-in support for multiple queue backends:
49 |
50 | * **[Redis][]** - Two implementations available: [phpredis][] (native extension) or [Predis][] (pure PHP)
51 | * **[Amazon SQS][]** - AWS Simple Queue Service integration
52 | * **[RabbitMQ][]** - Industry-standard message broker
53 | * **[ZeroMQ][]** - Available as a separate package: [tomaj/hermes-zmq-driver](https://github.com/tomaj/hermes-zmq-driver)
54 |
55 | **Note:** You need to install the corresponding client libraries for your chosen driver. For example, to use Redis with Predis, add `predis/predis` to your `composer.json` and configure your Redis connection.
56 |
57 | [Amazon SQS]: https://aws.amazon.com/sqs/
58 | [php-zmq]: https://zeromq.org/
59 | [phpredis]: https://github.com/phpredis/phpredis
60 | [Redis]: https://redis.io/
61 | [RabbitMQ]: https://www.rabbitmq.com/
62 | [Predis]: https://github.com/nrk/predis
63 | [ZeroMQ]: https://zeromq.org/
64 |
65 |
66 | ## Concept - How Hermes Works
67 |
68 | Hermes acts as a message broker between your web application and background workers. Here's the flow:
69 |
70 | ```
71 | +--------------------------------------------------------+
72 | | Web Application (HTTP Request) |
73 | | |
74 | | /file.php --> emit(Message) --> Hermes Emitter |
75 | +------------------------+-------------------------------+
76 | |
77 | v
78 | +-------------+
79 | | Queue |
80 | | Redis/Rabbit|
81 | +------+------+
82 | |
83 | v
84 | +--------------------------------------------------------+
85 | | Background Worker (PHP CLI) |
86 | | |
87 | | Dispatcher --> wait() --> Handler::handle() |
88 | | |
89 | | * Continuously listens for new messages |
90 | | * Calls registered handler to process each message |
91 | +--------------------------------------------------------+
92 | ```
93 |
94 | Implementation steps:
95 |
96 | 1. **Choose a driver**: Select a queue backend (Redis, RabbitMQ, etc.) and register it with the Dispatcher and Emitter
97 | 2. **Emit messages**: Send messages to the queue when you need background processing
98 | 3. **Create handlers**: Write handler classes to process your messages
99 | 4. **Run the worker**: Create a PHP CLI script that runs continuously to process messages from the queue
100 |
101 |
102 | ## How to Use
103 |
104 | This example demonstrates using the Redis driver to send emails in the background.
105 |
106 | ### Emitting Messages
107 |
108 | Emit messages from anywhere in your application—it's quick and straightforward:
109 |
110 | ```php
111 | use Redis;
112 | use Tomaj\Hermes\Message;
113 | use Tomaj\Hermes\Emitter;
114 | use Tomaj\Hermes\Driver\RedisSetDriver;
115 |
116 | $redis = new Redis();
117 | $redis->connect('127.0.0.1', 6379);
118 | $driver = new RedisSetDriver($redis);
119 | $emitter = new Emitter($driver);
120 |
121 | $message = new Message('send-email', [
122 | 'to' => 'test@test.com',
123 | 'subject' => 'Testing hermes email',
124 | 'message' => 'Hello from hermes!'
125 | ]);
126 |
127 | $emitter->emit($message);
128 | ```
129 |
130 | ### Processing Messages
131 |
132 | To process messages, create a PHP CLI script that runs continuously. Here's a simple implementation with a handler:
133 |
134 |
135 | ```php
136 | # file handler.php
137 | use Redis;
138 | use Tomaj\Hermes\Driver\RedisSetDriver;
139 | use Tomaj\Hermes\Dispatcher;
140 | use Tomaj\Hermes\Handler\HandlerInterface;
141 |
142 | class SendEmailHandler implements HandlerInterface
143 | {
144 | // here you will receive message that was emitted from web application
145 | public function handle(MessageInterface $message)
146 | {
147 | $payload = $message->getPayload();
148 | mail($payload['to'], $payload['subject'], $payload['message']);
149 | return true;
150 | }
151 | }
152 |
153 |
154 | // create dispatcher like in the first snippet
155 | $redis = new Redis();
156 | $redis->connect('127.0.0.1', 6379);
157 | $driver = new RedisSetDriver($redis);
158 | $dispatcher = new Dispatcher($driver);
159 |
160 | // register handler for event
161 | $dispatcher->registerHandler('send-email', new SendEmailHandler());
162 |
163 | // at this point this script will wait for new message
164 | $dispatcher->handle();
165 | ```
166 |
167 | To keep the worker running continuously on your server, use a process manager like [supervisord][], [upstart][], [monit][], or [god][].
168 |
169 | [upstart]: http://upstart.ubuntu.com/
170 | [supervisord]: http://supervisord.org
171 | [monit]: https://mmonit.com/monit/
172 | [god]: http://godrb.com/
173 |
174 | ## Logging
175 |
176 | Hermes supports any PSR-3 compliant logger. Set a logger for the Dispatcher or Emitter to track message flow and handler execution.
177 |
178 | To enable logging in your handlers, add the `Psr\Log\LoggerAwareTrait` trait (or implement `Psr\Log\LoggerAwareInterface`)—the Dispatcher and Emitter will automatically inject the logger.
179 |
180 | Example using [monolog][]:
181 |
182 | ```php
183 |
184 | use Monolog\Logger;
185 | use Monolog\Handler\StreamHandler;
186 |
187 | // create a log channel
188 | $log = new Logger('hermes');
189 | $log->pushHandler(new StreamHandler('hermes.log'));
190 |
191 | // $driver = ....
192 |
193 | $dispatcher = new Dispatcher($driver, $log);
194 | ```
195 |
196 | To add logging within your handlers:
197 |
198 | ```php
199 | use Redis;
200 | use Tomaj\Hermes\Driver\RedisSetDriver;
201 | use Tomaj\Hermes\Dispatcher;
202 | use Tomaj\Hermes\Handler\HandlerInterface;
203 | use Psr\Log\LoggerAwareTrait;
204 |
205 | class SendEmailHandlerWithLogger implements HandlerInterface
206 | {
207 | // enable logger
208 | use LoggerAwareTrait;
209 |
210 | public function handle(MessageInterface $message)
211 | {
212 | $payload = $message->getPayload();
213 |
214 | // log info message
215 | $this->logger->info("Trying to send email to {$payload['to']}");
216 |
217 | mail($payload['to'], $payload['subject'], $payload['message']);
218 | return true;
219 | }
220 | }
221 |
222 | ```
223 |
224 | ## Retry
225 |
226 | If your handler fails, you can automatically retry by adding the `RetryTrait` to your handler class. Override the `maxRetry()` method to control the number of retry attempts (default is 25).
227 |
228 | **Note:** Retry functionality requires a driver that supports delayed execution (the `$executeAt` message parameter).
229 |
230 | ```php
231 | declare(strict_types=1);
232 |
233 | namespace Tomaj\Hermes\Handler;
234 |
235 | use Tomaj\Hermes\MessageInterface;
236 |
237 | class EchoHandler implements HandlerInterface
238 | {
239 | use RetryTrait;
240 |
241 | public function handle(MessageInterface $message): bool
242 | {
243 | throw new \Exception('this will always fail');
244 | }
245 |
246 | // optional - default is 25
247 | public function maxRetry(): int
248 | {
249 | return 10;
250 | }
251 | }
252 | ```
253 |
254 | ## Priorities
255 |
256 | You can configure multiple queues with different priority levels to ensure high-priority messages are processed first.
257 |
258 | Example with Redis driver:
259 | ```php
260 | use Tomaj\Hermes\Driver\RedisSetDriver;
261 | use Tomaj\Hermes\Emitter;
262 | use Tomaj\Hermes\Message;
263 | use Tomaj\Hermes\Dispatcher;
264 |
265 | $redis = new Redis();
266 | $redis->connect('127.0.0.1', 6379);
267 | $driver = new RedisSetDriver($redis);
268 | $driver->setupPriorityQueue('hermes_low', Dispatcher::DEFAULT_PRIORITY - 10);
269 | $driver->setupPriorityQueue('hermes_high', Dispatcher::DEFAULT_PRIORITY + 10);
270 |
271 | $emitter = new Emitter($driver);
272 | $emitter->emit(new Message('type1', ['a' => 'b'], Dispatcher::DEFAULT_PRIORITY - 10));
273 | $emitter->emit(new Message('type1', ['c' => 'd'], Dispatcher::DEFAULT_PRIORITY + 10));
274 | ```
275 |
276 | Key points about priorities:
277 | - Use priority constants from the `Dispatcher` class or any numeric value
278 | - Higher numbers indicate higher priority
279 | - You can pass an array of queue names to `Dispatcher::handle()` to create workers that process specific queues
280 |
281 | ## Graceful Shutdown
282 |
283 | Hermes workers can be gracefully stopped without losing messages.
284 |
285 | When you provide an implementation of `Tomaj\Hermes\Shutdown\ShutdownInterface` to the `Dispatcher`, Hermes checks `ShutdownInterface::shouldShutdown()` after each message. If it returns `true`, the worker shuts down cleanly.
286 |
287 | **Important:** Hermes handles shutdown, but automatic restart must be managed by your process controller (e.g., supervisord, systemd, or Docker).
288 |
289 | Two shutdown implementations are available:
290 |
291 | ### SharedFileShutdown
292 |
293 | Trigger shutdown by creating or touching a specific file:
294 |
295 | ```php
296 | $shutdownFile = '/tmp/hermes_shutdown';
297 | $shutdown = Tomaj\Hermes\Shutdown\SharedFileShutdown($shutdownFile);
298 |
299 | // $log = ...
300 | // $driver = ....
301 | $dispatcher = new Dispatcher($driver, $log, $shutdown);
302 |
303 | // ...
304 |
305 | // shutdown can be triggered be calling `ShutdownInterface::shutdown()`
306 | $shutdown->shutdown();
307 | ```
308 |
309 | ### RedisShutdown
310 |
311 | Trigger shutdown by setting a Redis key:
312 |
313 | ```php
314 | $redisClient = new Predis\Client();
315 | $redisShutdownKey = 'hermes_shutdown'; // can be omitted; default value is `hermes_shutdown`
316 | $shutdown = Tomaj\Hermes\Shutdown\RedisShutdown($redisClient, $redisShutdownKey);
317 |
318 | // $log = ...
319 | // $driver = ....
320 | $dispatcher = new Dispatcher($driver, $log, $shutdown);
321 |
322 | // ...
323 |
324 | // shutdown can be triggered be calling `ShutdownInteface::shutdown()`
325 | $shutdown->shutdown();
326 | ```
327 |
328 | ## Scaling Hermes
329 |
330 | Hermes can easily scale to handle high message volumes. Simply run multiple worker instances—either on the same machine or distributed across multiple servers.
331 |
332 | Requirements for scaling:
333 | 1. **Network-capable driver**: Your driver must support remote connections (Redis, RabbitMQ, and Amazon SQS all support this)
334 | 2. **At-most-once delivery**: Each message should be delivered to only one worker
335 |
336 | Both Redis and RabbitMQ drivers satisfy these requirements and are designed for high-throughput scenarios.
337 |
338 | ## Extending Hermes
339 |
340 | Hermes uses interface-based architecture, making it easy to extend. You can create custom drivers, use different loggers, or implement your own message serialization.
341 |
342 | ### Creating a Custom Driver
343 |
344 | Each driver must implement `Tomaj\Hermes\Driver\DriverInterface` with two methods: `send()` and `wait()`.
345 |
346 | Here's an example driver using [Gearman][]:
347 |
348 | ```PHP
349 | namespace My\Custom\Driver;
350 |
351 | use Tomaj\Hermes\Driver\DriverInterface;
352 | use Tomaj\Hermes\Message;
353 | use Closure;
354 |
355 | class GearmanDriver implements DriverInterface
356 | {
357 | private $client;
358 |
359 | private $worker;
360 |
361 | private $channel;
362 |
363 | private $serializer;
364 |
365 | public function __construct(GearmanClient $client, GearmanWorker $worker, $channel = 'hermes')
366 | {
367 | $this->client = $client;
368 | $this->worker = $worker;
369 | $this->channel = $channel;
370 | $this->serializer = $serialier;
371 | }
372 |
373 | public function send(Message $message)
374 | {
375 | $this->client->do($this->channel, $this->serializer->serialize($message));
376 | }
377 |
378 | public function wait(Closure $callback)
379 | {
380 | $worker->addFunction($this->channel, function ($gearmanMessage) use ($callback) {
381 | $message = $this->serializer->unserialize($gearmanMessage);
382 | $callback($message);
383 | });
384 | while ($this->worker->work());
385 | }
386 | }
387 | ```
388 |
389 | [Gearman]: http://gearman.org/
390 |
391 | ### Creating a Custom Serializer
392 |
393 | To use custom serialization, create a class that implements `Tomaj\Hermes\SerializerInterface`. Add the `Tomaj\Hermes\Driver\SerializerAwareTrait` to your driver to enable the `setSerializer()` method.
394 |
395 | Example using [jms/serializer][]:
396 |
397 | ```php
398 | namespace My\Custom\Serializer;
399 |
400 | use Tomaj\Hermes\SerializerInterface;
401 | use Tomaj\Hermes\MessageInterface;
402 |
403 | class JmsSerializer implements SerializerInterface
404 | {
405 | public function serialize(MessageInterface $message)
406 | {
407 | $serializer = JMS\Serializer\SerializerBuilder::create()->build();
408 | return $serializer->serialize($message, 'json');
409 | }
410 |
411 | public function unserialize($string)
412 | {
413 | $serializer = JMS\Serializer\SerializerBuilder::create()->build();
414 | return $serializer->deserialize($message, 'json');
415 | }
416 | }
417 | ```
418 |
419 | [jms/serializer]: http://jmsyst.com/libs/serializer
420 |
421 |
422 |
423 | ### Scheduled Execution
424 |
425 | Since version 2.0, you can schedule messages for future execution by passing a timestamp as the fourth parameter to the `Message` constructor. Currently supported by `RedisSetDriver` and `PredisSetDriver`.
426 |
427 | ## Upgrade Guide
428 |
429 | ### From v3 to v4
430 |
431 | **Breaking Changes:**
432 | - **Renamed Restart → Shutdown** to better reflect functionality. Hermes can gracefully stop its own process, but restarting must be handled by an external process manager.
433 | - `RestartInterface` → `ShutdownInterface`
434 | - All implementation classes and namespaces have been updated accordingly
435 |
436 | ## Changelog
437 |
438 | See [CHANGELOG](CHANGELOG.md) for a detailed list of changes and version history.
439 |
440 | ## Testing
441 |
442 | ``` bash
443 | $ composer test
444 | ```
445 |
446 | ### Code Coverage
447 |
448 | To generate code coverage reports:
449 |
450 | ``` bash
451 | # Generate coverage reports locally
452 | $ composer coverage
453 | # or use the helper script
454 | $ ./coverage.sh
455 | ```
456 |
457 | The coverage reports will be generated in:
458 | - **HTML report**: `build/coverage/index.html` (open in browser to see line-by-line coverage)
459 | - **Clover XML**: `build/logs/clover.xml` (for CI/CD integration)
460 |
461 | **Online Coverage Reports**: Coverage reports are automatically published to GitHub Pages after each successful test run on the main branch.
462 |
463 | ## Contributing
464 |
465 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CONDUCT](CONDUCT.md) for details.
466 |
467 | ## Security
468 |
469 | If you discover any security-related issues, please email tomasmajer@gmail.com instead of using the issue tracker.
470 |
471 | ## License
472 |
473 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
474 |
--------------------------------------------------------------------------------