├── 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 | [![Latest Stable Version](https://img.shields.io/packagist/v/tomaj/hermes.svg)](https://packagist.org/packages/tomaj/hermes) 6 | [![PHPStan](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg)](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 | --------------------------------------------------------------------------------