diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..e9318a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,127 @@ +name: Bug report + +description: Create a report to help us improve CodeIgniter4 Queue +title: "Bug: " +labels: ['bug'] + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + Before you begin, **please ensure that there are no existing issues, + whether still open or closed, related to your report**. + If there is, your report will be closed promptly. + + For example, if you encounter an issue with queue processing, + you can search the GitHub repository with relevant keywords. + + --- + + - type: input + id: php-version + attributes: + label: PHP Version + description: | + e.g. 8.2.0 + validations: + required: true + + - type: input + id: codeigniter-version + attributes: + label: CodeIgniter4 Version + description: | + e.g. 4.5.0 + validations: + required: true + + - type: input + id: queue-version + attributes: + label: Queue Package Version + description: | + e.g. dev:develop, 1.0.0 and ... + If you are not using the [latest version](https://github.com/codeigniter4/queue/releases), please + check to see if the problem occurs with the latest version. + validations: + required: true + + - type: dropdown + id: operating-systems + attributes: + label: Which operating systems have you tested for this bug? + description: You may select more than one. + multiple: true + options: + - macOS + - Windows + - Linux + validations: + required: true + + - type: dropdown + id: server + attributes: + label: Which server did you use? + options: + - apache + - cli + - cli-server (PHP built-in webserver) + - cgi-fcgi + - fpm-fcgi + - phpdbg + validations: + required: true + + - type: input + id: queue-driver + attributes: + label: Queue Driver + description: | + e.g. database, redis, predis + validations: + required: true + + - type: textarea + id: queue-configuration + attributes: + label: Queue Configuration + description: | + Please provide your queue configuration settings. + **Important:** Before sharing, ensure that all sensitive data such as passwords, API keys, and secrets are masked or removed. + validations: + required: true + + - type: textarea + id: description + attributes: + label: What happened? + placeholder: Tell us what you see! + validations: + required: true + + - type: textarea + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + validations: + required: true + + - type: textarea + attributes: + label: Expected Output + description: What do you expect to happen instead of this filed bug? + validations: + required: true + + - type: textarea + attributes: + label: Anything else? + description: | + Links? References? Anything that will give us more context about the issue you are encountering! + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + validations: + required: false \ No newline at end of file diff --git a/.github/workflows/deptrac.yml b/.github/workflows/deptrac.yml index 2021f8d..78b7ecf 100644 --- a/.github/workflows/deptrac.yml +++ b/.github/workflows/deptrac.yml @@ -7,7 +7,7 @@ on: paths: - '**.php' - 'composer.*' - - 'depfile.yaml' + - 'deptrac.yaml' - '.github/workflows/deptrac.yml' push: branches: @@ -15,60 +15,9 @@ on: paths: - '**.php' - 'composer.*' - - 'depfile.yaml' + - 'deptrac.yaml' - '.github/workflows/deptrac.yml' jobs: - build: - name: Dependency Tracing - runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.1' - tools: phive - extensions: intl, json, mbstring, xml - coverage: none - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Create Deptrac cache directory - run: mkdir -p build/ - - - name: Cache Deptrac results - uses: actions/cache@v3 - with: - path: build - key: ${{ runner.os }}-deptrac-${{ github.sha }} - restore-keys: ${{ runner.os }}-deptrac- - - - name: Install dependencies - run: | - if [ -f composer.lock ]; then - composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader - else - composer update --no-progress --no-interaction --prefer-dist --optimize-autoloader - fi - - - name: Trace dependencies - run: | - sudo phive --no-progress install --global --trust-gpg-keys B8F640134AB1782E,A98E898BB53EB748 qossmic/deptrac - deptrac analyze --cache-file=build/deptrac.cache - env: - GITHUB_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + deptrac: + uses: codeigniter4/.github/.github/workflows/deptrac.yml@CI46 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e52cbad..0937a92 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,8 +5,9 @@ on: branches: - develop paths: - - 'docs/*' + - 'docs/**/*' - 'mkdocs.yml' + - '.github/workflows/docs.yml' permissions: contents: write @@ -16,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - run: pip install mkdocs-material diff --git a/.github/workflows/phpcsfixer.yml b/.github/workflows/phpcsfixer.yml index 59c867c..9ac8b7f 100644 --- a/.github/workflows/phpcsfixer.yml +++ b/.github/workflows/phpcsfixer.yml @@ -37,7 +37,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - name: Cache composer dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ env.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 9433d3a..ff29de7 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -46,7 +46,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - name: Cache composer dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ env.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} @@ -56,7 +56,7 @@ jobs: run: mkdir -p build/phpstan - name: Cache PHPStan results - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: build/phpstan key: ${{ runner.os }}-phpstan-${{ github.sha }} diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 89829ba..8401939 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -18,12 +18,95 @@ on: - 'phpunit*' - '.github/workflows/phpunit.yml' +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +env: + NLS_LANG: 'AMERICAN_AMERICA.UTF8' + NLS_DATE_FORMAT: 'YYYY-MM-DD HH24:MI:SS' + NLS_TIMESTAMP_FORMAT: 'YYYY-MM-DD HH24:MI:SS' + NLS_TIMESTAMP_TZ_FORMAT: 'YYYY-MM-DD HH24:MI:SS' + jobs: main: - name: PHP ${{ matrix.php-versions }} Unit Tests + name: PHP ${{ matrix.php-versions }} - ${{ matrix.db-platforms }} runs-on: ubuntu-22.04 + if: "!contains(github.event.head_commit.message, '[ci skip]')" + strategy: + matrix: + php-versions: ['8.1', '8.2', '8.3'] + db-platforms: ['MySQLi', 'SQLite3'] + include: + # Postgre + - php-versions: '8.1' + db-platforms: Postgre + # SQLSRV + - php-versions: '8.1' + db-platforms: SQLSRV + # OCI8 + - php-versions: '8.1' + db-platforms: OCI8 services: + mysql: + image: mysql:8.0 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test + ports: + - 5432:5432 + options: >- + --health-cmd=pg_isready + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + MSSQL_SA_PASSWORD: 1Secure*Password1 + ACCEPT_EULA: Y + MSSQL_PID: Developer + ports: + - 1433:1433 + options: >- + --health-cmd="/opt/mssql-tools18/bin/sqlcmd -C -S 127.0.0.1 -U sa -P 1Secure*Password1 -Q 'SELECT @@VERSION'" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + oracle: + image: gvenzl/oracle-xe:21 + env: + ORACLE_RANDOM_PASSWORD: true + APP_USER: ORACLE + APP_USER_PASSWORD: ORACLE + ports: + - 1521:1521 + options: >- + --health-cmd healthcheck.sh + --health-interval 20s + --health-timeout 10s + --health-retries 10 + redis: image: redis ports: @@ -34,12 +117,40 @@ jobs: --health-timeout=5s --health-retries=3 - if: "!contains(github.event.head_commit.message, '[ci skip]')" - strategy: - matrix: - php-versions: ['8.1', '8.2'] + rabbitmq: + image: rabbitmq + env: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + ports: + - 5672 + options: >- + --health-cmd="rabbitmq-diagnostics -q ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + # this might remove tools that are actually needed, + # if set to "true" but frees about 6 GB + tool-cache: false + + # all of these default to true, but feel free to set to + # "false" if necessary for your workflow + android: true + dotnet: true + haskell: true + large-packages: false + docker-images: true + swap-storage: true + + - name: Create database for MSSQL Server + if: matrix.db-platforms == 'SQLSRV' + run: sqlcmd -S 127.0.0.1 -U sa -P 1Secure*Password1 -Q "CREATE DATABASE test" + - name: Checkout uses: actions/checkout@v4 @@ -48,7 +159,7 @@ jobs: with: php-version: ${{ matrix.php-versions }} tools: composer, phive, phpunit - extensions: intl, json, mbstring, gd, xdebug, xml, sqlite3, redis + extensions: intl, json, mbstring, gd, xdebug, xml, sqlite3, sqlsrv, oci8, pgsql coverage: xdebug env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -57,7 +168,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - name: Cache composer dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ env.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} @@ -72,8 +183,9 @@ jobs: fi - name: Test with PHPUnit - run: vendor/bin/phpunit --verbose --coverage-text + run: vendor/bin/phpunit --coverage-text env: + DB: ${{ matrix.db-platforms }} TERM: xterm-256color TACHYCARDIA_MONITOR_GA: enabled @@ -86,12 +198,12 @@ jobs: env: COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_PARALLEL: true - COVERALLS_FLAG_NAME: PHP ${{ matrix.php-versions }} + COVERALLS_FLAG_NAME: PHP ${{ matrix.php-versions }} - ${{ matrix.db-platforms }} coveralls: needs: [main] name: Coveralls Finished - if: github.repository_owner == 'michalsn' + if: github.repository_owner == 'codeigniter4' runs-on: ubuntu-latest steps: - name: Upload Coveralls results diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index b28b1fc..1d3c7cb 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -42,7 +42,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - name: Cache composer dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ env.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} @@ -52,7 +52,7 @@ jobs: run: mkdir -p build/psalm - name: Cache Psalm results - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: build/psalm key: ${{ runner.os }}-psalm-${{ github.sha }} diff --git a/.github/workflows/rector.yml b/.github/workflows/rector.yml index f4482bf..cab67ce 100644 --- a/.github/workflows/rector.yml +++ b/.github/workflows/rector.yml @@ -18,15 +18,22 @@ on: - 'rector.php' - '.github/workflows/rector.yml' +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + jobs: build: name: PHP ${{ matrix.php-versions }} Rector Analysis runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[ci skip]')" + if: (! contains(github.event.head_commit.message, '[ci skip]')) strategy: fail-fast: false matrix: - php-versions: ['8.1', '8.2'] + php-versions: ['8.1', '8.3'] steps: - name: Checkout @@ -46,7 +53,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - name: Cache composer dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ env.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} @@ -61,6 +68,4 @@ jobs: fi - name: Analyze for refactoring - run: | - composer global require --dev rector/rector:^0.15.1 - rector process --dry-run --no-progress-bar + run: vendor/bin/rector process --dry-run --no-progress-bar diff --git a/.gitignore b/.gitignore index 74c6d0c..8a589c5 100644 --- a/.gitignore +++ b/.gitignore @@ -126,5 +126,5 @@ nb-configuration.xml /results/ /phpunit*.xml /.phpunit.*.cache - +/.phpunit.cache /.php-cs-fixer.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 1185b9c..001fd00 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + use CodeIgniter\CodingStandard\CodeIgniter4; use Nexus\CsConfig\Factory; use PhpCsFixer\Finder; @@ -13,11 +24,18 @@ ->exclude('build') ->append([__FILE__]); -$overrides = []; +$overrides = [ + 'declare_strict_types' => true, + 'void_return' => true, +]; $options = [ 'finder' => $finder, 'cacheFile' => 'build/.php-cs-fixer.cache', ]; -return Factory::create(new CodeIgniter4(), $overrides, $options)->forProjects(); +return Factory::create(new CodeIgniter4(), $overrides, $options)->forLibrary( + 'CodeIgniter Queue', + 'CodeIgniter Foundation', + 'admin@codeigniter.com', +); diff --git a/README.md b/README.md index 8ab9029..f4c6ee0 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ Queues for the CodeIgniter 4 framework. ![PHP](https://img.shields.io/badge/PHP-%5E8.1-blue) ![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.3-blue) +![License](https://img.shields.io/badge/License-MIT-blue) + +> [!NOTE] +> A queue system is typically used to handle resource-intensive or time-consuming tasks (e.g., image processing, sending emails) that are to be run in the background. It can also be a way to postpone certain activities that are to be executed automatically later. ## Installation @@ -58,4 +62,11 @@ Run the queue worker: ## Docs -https://codeigniter4.github.io/queue/ +Read the full documentation: https://queue.codeigniter.com + +## Contributing + +We accept and encourage contributions from the community in any shape. It doesn't matter +whether you can code, write documentation, or help find bugs, all contributions are welcome. +See the [CONTRIBUTING.md](CONTRIBUTING.md) file for details. + diff --git a/composer.json b/composer.json index e6b709f..c0c6084 100644 --- a/composer.json +++ b/composer.json @@ -18,14 +18,19 @@ "require-dev": { "codeigniter4/devkit": "^1.0", "codeigniter4/framework": "^4.3", - "predis/predis": "^2.0" + "predis/predis": "^2.0", + "phpstan/phpstan-strict-rules": "^1.5", + "php-amqplib/php-amqplib": "^3.7" }, "minimum-stability": "dev", "prefer-stable": true, "autoload": { "psr-4": { "CodeIgniter\\Queue\\": "src" - } + }, + "exclude-from-classmap": [ + "**/Database/Migrations/**" + ] }, "autoload-dev": { "psr-4": { @@ -34,7 +39,8 @@ }, "suggest": { "ext-redis": "If you want to use RedisHandler", - "predis/predis": "If you want to use PredisHandler" + "predis/predis": "If you want to use PredisHandler", + "php-amqplib/php-amqplib": "If you want to use RabbitMQHandler" }, "config": { "allow-plugins": { @@ -59,7 +65,7 @@ "cs": "php-cs-fixer fix --ansi --verbose --dry-run --diff", "cs-fix": "php-cs-fixer fix --ansi --verbose --diff", "style": "@cs-fix", - "deduplicate": "phpcpd app/ src/", + "deduplicate": "phpcpd src/ tests/", "inspect": "deptrac analyze --cache-file=build/deptrac.cache", "mutate": "infection --threads=2 --skip-initial-tests --coverage=build/phpunit", "test": "phpunit" diff --git a/deptrac.yaml b/deptrac.yaml index 21a7a89..3caa730 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -1,4 +1,4 @@ -parameters: +deptrac: paths: - ./src/ - ./vendor/codeigniter4/framework/system/ @@ -9,90 +9,90 @@ parameters: collectors: - type: bool must: - - type: className - regex: .*[A-Za-z]+Model$ + - type: class + value: .*[A-Za-z]+Model$ must_not: - - type: directory - regex: vendor/.* + - type: directory + value: vendor/.* - name: Vendor Model collectors: - type: bool must: - - type: className - regex: .*[A-Za-z]+Model$ - - type: directory - regex: vendor/.* + - type: class + value: .*[A-Za-z]+Model$ + - type: directory + value: vendor/.* - name: Controller collectors: - type: bool must: - - type: className - regex: .*\/Controllers\/.* + - type: class + value: .*\/Controllers\/.* must_not: - - type: directory - regex: vendor/.* + - type: directory + value: vendor/.* - name: Vendor Controller collectors: - type: bool must: - - type: className - regex: .*\/Controllers\/.* - - type: directory - regex: vendor/.* + - type: class + value: .*\/Controllers\/.* + - type: directory + value: vendor/.* - name: Config collectors: - type: bool must: - - type: directory - regex: app/Config/.* + - type: directory + value: app/Config/.* must_not: - - type: className - regex: .*Services - - type: directory - regex: vendor/.* + - type: class + value: .*Services + - type: directory + value: vendor/.* - name: Vendor Config collectors: - type: bool must: - - type: directory - regex: vendor/.*/Config/.* + - type: directory + value: vendor/.*/Config/.* must_not: - - type: className - regex: .*Services + - type: class + value: .*Services - name: Entity collectors: - type: bool must: - - type: directory - regex: app/Entities/.* + - type: directory + value: app/Entities/.* must_not: - - type: directory - regex: vendor/.* + - type: directory + value: vendor/.* - name: Vendor Entity collectors: - type: bool must: - - type: directory - regex: vendor/.*/Entities/.* + - type: directory + value: vendor/.*/Entities/.* - name: View collectors: - type: bool must: - - type: directory - regex: app/Views/.* + - type: directory + value: app/Views/.* must_not: - - type: directory - regex: vendor/.* + - type: directory + value: vendor/.* - name: Vendor View collectors: - type: bool must: - - type: directory - regex: vendor/.*/Views/.* + - type: directory + value: vendor/.*/Views/.* - name: Service collectors: - - type: className - regex: .*Services.* + - type: class + value: .*Services.* ruleset: Entity: - Config @@ -153,4 +153,4 @@ parameters: - Vendor Entity - Vendor Model - Vendor View - skip_violations: + skip_violations: [] diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..ec5fa62 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +queue.codeigniter.com diff --git a/docs/assets/js/hljs.js b/docs/assets/js/hljs.js index 6f9098a..56159c4 100644 --- a/docs/assets/js/hljs.js +++ b/docs/assets/js/hljs.js @@ -1,3 +1,3 @@ -document.addEventListener('DOMContentLoaded', (event) => { +window.document$.subscribe(() => { hljs.highlightAll(); }); diff --git a/docs/basic-usage.md b/docs/basic-usage.md index afe403f..b91ae37 100644 --- a/docs/basic-usage.md +++ b/docs/basic-usage.md @@ -79,6 +79,52 @@ You may be wondering what the `$this->data['message']` variable is all about. We Throwing an exception is a way to let the queue worker know that the job has failed. +#### Using transactions + +If you have to use transactions in your Job - this is a simple schema you can follow. + +!!! note + + Due to the nature of the queue worker, [Strict Mode](https://codeigniter.com/user_guide/database/transactions.html#strict-mode) is automatically disabled for the database connection assigned to the Database handler. That's because queue worker is a long-running process, and we don't want one failed transaction to affect others. + + If you use the same connection group in your Job as defined in the Database handler, then in that case, you don't need to do anything. + + On the other hand, if you are using a different group to connect to the database in your Job, then if you are using transactions, you should disable Strict Mode through the method: `$db->transStrict(false)` or by setting the `transStrict` option to `false` in your connection config group - the last option will disable Strict Mode globally. + +```php +// ... + +class Email extends BaseJob implements JobInterface +{ + /** + * @throws Exception + */ + public function process(string $data): + { + try { + $db = db_connect(); + // Disable Strict Mode + $db->transStrict(false); + $db->transBegin(); + + // Job logic goes here + // Your code should throw an exception on error + + if ($db->transStatus() === false) { + $db->transRollback(); + } else { + $db->transCommit(); + } + } catch (Exception $e) { + $db->transRollback(); + throw $e; + } + } +} +``` + +#### Other options + We can also configure some things on the job level. It's a number of tries, when the job is failing and time after the job will be retried again after failure. We can specify these options by using variables: ```php @@ -114,6 +160,29 @@ service('queue')->push('emails', 'email', ['message' => 'Email message goes here We will be pushing `email` job to the `emails` queue. +As a result of calling the `push()` method, you will receive a `QueuePushResult` object, which you can inspect if needed. It provides the following information: + +- `getStatus()`: Indicates whether the job was successfully added to the queue. +- `getJobId()`: Returns the ID of the job that was added to the queue. +- `getError()`: Returns any error that occurred if the job was not added. + +### Sending chained jobs to the queue + +Sending chained jobs is also simple and lets you specify the particular order of the job execution. + +```php +service('queue')->chain(function($chain) { + $chain + ->push('reports', 'generate-report', ['userId' => 123]) + ->push('emails', 'email', ['message' => 'Email message goes here', 'userId' => 123]); +}); +``` + +In the example above, we will send jobs to the `reports` and `emails` queues. First, we will generate a report for given user with the `generate-report` job, after this, we will send an email with `email` job. +The `email` job will be executed only if the `generate-report` job was successful. + +As with the `push()` method, calling the `chain()` method also returns a `QueuePushResult` object. + ### Consuming the queue Since we sent our sample job to queue `emails`, then we need to run the worker with the appropriate queue: diff --git a/docs/configuration.md b/docs/configuration.md index 858713e..de7b56d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,7 @@ Available options: - [$database](#database) - [$redis](#redis) - [$predis](#predis) +- [$rabbitmq](#rabbitmq) - [$keepDoneJobs](#keepdonejobs) - [$keepFailedJobs](#keepfailedjobs) - [$queueDefaultPriority](#queuedefaultpriority) @@ -29,7 +30,7 @@ The default handler used by the library. Default value: `database`. ### $handlers -An array of available handlers. By now only `database`, `redis` and `predis` handlers are implemented. +An array of available handlers. Available handlers: `database`, `redis`, `predis`, and `rabbitmq`. ### $database @@ -37,6 +38,11 @@ The configuration settings for `database` handler. * `dbGroup` - The database group to use. Default value: `default`. * `getShared` - Weather to use shared instance. Default value: `true`. +* `skipLocked` - Weather to use "skip locked" feature to maintain concurrency calls. Default to `true`. + +!!! note + + The [Strict Mode](https://codeigniter.com/user_guide/database/transactions.html#strict-mode) for the given `dbGroup` is automatically disabled - due to the nature of the queue worker. ### $redis @@ -61,6 +67,16 @@ The configuration settings for `predis` handler. You need to have [Predis](https * `database` - The database number. Default value: `0`. * `prefix` - The default key prefix. Default value: `''` (not set). +### $rabbitmq + +The configuration settings for `rabbitmq` handler. You need to have [php-amqplib](https://github.com/php-amqplib/php-amqplib) installed to use it. + +* `host` - The RabbitMQ server host. Default value: `127.0.0.1`. +* `port` - The port number. Default value: `5672`. +* `username` - The username for authentication. Default value: `guest`. +* `password` - The password for authentication. Default value: `guest`. +* `vhost` - The virtual host to use. Default value: `/`. + ### $keepDoneJobs If the job is done, should we keep it in the table? Default value: `false`. diff --git a/docs/index.md b/docs/index.md index 7d3c93a..58ad9ca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,11 @@ A library that helps you handle Queues in the CodeIgniter 4 framework. -Add job to the queue. +!!! info "What are queues used for?" + + A queue system is typically used to handle resource-intensive or time-consuming tasks (e.g., image processing, sending emails) that are to be run in the background. It can also be a way to postpone certain activities that are to be executed automatically later. + +Add a job to the queue. ```php service('queue')->push('queueName', 'jobName', ['array' => 'parameters']); @@ -14,14 +18,43 @@ Listen for queued jobs. ### Requirements -![PHP](https://img.shields.io/badge/PHP-%5E8.1-red) -![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.3-red) +- PHP 8.1+ +- CodeIgniter 4.3+ + +If you use `database` handler: + +- MySQL 8.0.1+ +- MariaDB 10.6+ +- PostgreSQL 9.5+ +- SQL Server 2012+ +- Oracle 12.1+ +- SQLite3 + +If you use `Redis` (you still need a relational database to store failed jobs): + +- PHPRedis +- Predis + +If you use `RabbitMQ` (you still need a relational database to store failed jobs): + +- php-amqplib ### Table of Contents * [Installation](installation.md) * [Configuration](configuration.md) -* [Basic usage](basic-usage) -* [Running queues](running-queues) +* [Basic usage](basic-usage.md) +* [Running queues](running-queues.md) * [Commands](commands.md) * [Troubleshooting](troubleshooting.md) + +### Acknowledgements + +Every open-source project depends on its contributors to be a success. The following users have +contributed in one manner or another in making this project: + + + Contributors + + +Made with [contrib.rocks](https://contrib.rocks). diff --git a/docs/installation.md b/docs/installation.md index fd9df7b..0363de1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -10,6 +10,31 @@ The only thing you have to do is to run this command, and you're ready to go. composer require codeigniter4/queue +#### A composer error occurred? + +If you get the following error: + +```console +Could not find a version of package codeigniter4/queue matching your minimum-stability (stable). +Require it with an explicit version constraint allowing its desired stability. +``` + +1. Run the following commands to change your [minimum-stability](https://getcomposer.org/doc/articles/versions.md#minimum-stability) in your project `composer.json`: + + ```console + composer config minimum-stability dev + composer config prefer-stable true + ``` + +2. Or specify an explicit version: + + ```console + composer require codeigniter4/queue:dev-develop + ``` + + The above specifies `develop` branch. + See + ## Manual Installation In the example below we will assume, that files from this project will be located in `app/ThirdParty/queue` directory. diff --git a/docs/running-queues.md b/docs/running-queues.md index 10d4139..5bae1c0 100644 --- a/docs/running-queues.md +++ b/docs/running-queues.md @@ -12,7 +12,7 @@ This will cause command to check for the new jobs every 10 seconds if the queue ### With CRON -Using queues with CRON is more challenging, but definitely doable. You can use command like this: +Using queues with CRON is more challenging but definitely doable. You can use command like this: php spark queue:work emails -max-jobs 20 --stop-when-empty @@ -63,13 +63,65 @@ But we can also run the worker like this: This way, worker will consume jobs with the `low` priority and then with `high`. The order set in the config file is override. +### Delaying jobs + +Normally, when we add jobs to a queue, they are run in the order in which we added them to the queue (FIFO - first in, first out). +Of course, there are also priorities, which we described in the previous section. But what about the scenario where we want to run a job, but not earlier than in 5 minutes? + +This is where job delay comes into play. We measure the delay in seconds. + +```php +// This job will be run not sooner than in 5 minutes +service('queue')->setDelay(5 * MINUTE)->push('emails', 'email', ['message' => 'Email sent no sooner than 5 minutes from now']); +``` + +Note that there is no guarantee that the job will run exactly in 5 minutes. If many new jobs are added to the queue (without a delay), it may take a long time before the delayed job is actually executed. + +We can also combine delayed jobs with priorities. + +### Chained jobs + +We can create sequences of jobs that run in a specific order. Each job in the chain will be executed after the previous job has completed successfully. + +```php +service('queue')->chain(function($chain) { + $chain + ->push('reports', 'generate-report', ['userId' => 123]) + ->setPriority('high') // optional + ->push('emails', 'email', ['message' => 'Email message goes here', 'userId' => 123]) + ->setDelay(30); // optional +}); +``` + +As you may notice, we can use the same options as in regular `push()` - we can set priority and delay, which are optional settings. + +#### Important Differences from Regular `push()` + +When using the `chain()` method, there are a few important differences compared to the regular `push()` method: + +1. **Method Order**: Unlike the regular `push()` method where you set the priority and delay before pushing the job, in a chain you must set these properties after calling `push()` for each job: + + ```php + // Regular push() - priority set before pushing + service('queue')->setPriority('high')->push('queue', 'job', []); + + // Chain push() - priority set after pushing + service('queue')->chain(function($chain) { + $chain->push('queue', 'job', [])->setPriority('high'); + }); + ``` + +2. **Configuration Scope**: Each configuration (priority, delay) only applies to the job that was just added to the chain. + ### Running many instances of the same queue -As mentioned above, sometimes we may want to have multiple instances of the same command running at the same time. The queue is safe to use in that scenario with all databases except `SQLite3` since it doesn't guarantee that the job will be selected only by one process. +As mentioned above, sometimes we may want to have multiple instances of the same command running at the same time. The queue is safe to use in that scenario with all databases as long as you keep the `skipLocked` to `true` in the config file. Only for SQLite3 driver, this setting is not relevant as it provides atomicity without the need for explicit concurrency control. + +The PHPRedis and Predis drivers are also safe to use with multiple instances of the same command. ### Handling long-running process -If we decide to run the long process e.g. with the command: +If we decide to run the long process, e.g., with the command: php spark queue:work emails -wait 10 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 58d91bc..10cd890 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5,3 +5,7 @@ If you want to assign an object to the queue, please make sure it implements `JsonSerializable` interface. This is how CodeIgniter [Entities](https://codeigniter.com/user_guide/models/entities.html) are handled by default. You may ask, why not just use `serialize` and `unserialize`? There are security reasons that keep us from doing so. These functions are not safe to use with user provided data. + +### I get an error when trying to install via composer. + +Please see these [instructions](installation.md/#a-composer-error-occurred). diff --git a/mkdocs.yml b/mkdocs.yml index 15e9447..b93a433 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,7 +24,6 @@ theme: name: Switch to light mode features: - navigation.instant - - navigation.instant.prefetch - content.code.copy - navigation.footer - content.action.edit @@ -51,9 +50,10 @@ extra: link: https://join.slack.com/t/codeigniterchat/shared_invite/zt-244xrrslc-l_I69AJSi5y2a2RVN~xIdQ name: Slack +site_url: https://queue.codeigniter.com/ repo_url: https://github.com/codeigniter4/queue edit_uri: edit/develop/docs/ -copyright: Copyright © 2023 CodeIgniter Foundation. +copyright: Copyright © 2025 CodeIgniter Foundation. markdown_extensions: - admonition diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ede8df0..45e4dd1 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -9,58 +9,23 @@ parameters: excludePaths: ignoreErrors: - - message: '#Cannot use \+\+ on array\|bool\|float\|int\|object\|string\|null.#' - paths: - - src/Commands/QueueWork.php - - - message: '#Variable \$config on left side of \?\?\= always exists and is not nullable.#' - paths: - - src/Config/Services.php - - - message: '#Call to an undefined method Michalsn\\CodeIgniterQueue\\Handlers\\BaseHandler::push\(\).#' - paths: - - src/Handlers/BaseHandler.php + message: '#Call to method PHPUnit\\Framework\\Assert::assertInstanceOf\(\) with.#' - message: '#Call to deprecated function random_string\(\):#' paths: - src/Handlers/RedisHandler.php - src/Handlers/PredisHandler.php + - src/Handlers/RabbitMQHandler.php - - message: '#Cannot access property \$timestamp on array\|bool\|float\|int\|object\|string.#' - paths: - - tests/_support/Database/Seeds/TestRedisQueueSeeder.php - - - message: '#Access to an undefined property CodeIgniter\\I18n\\Time::\$timestamp.#' + message: '#Call to an undefined method CodeIgniter\\Queue\\Models\\QueueJobFailedModel::affectedRows\(\).#' paths: - src/Handlers/BaseHandler.php - - src/Handlers/DatabaseHandler.php - - src/Handlers/RedisHandler.php - - src/Handlers/PredisHandler.php - - src/Models/QueueJobModel.php - - tests/RedisHandlerTest.php - - tests/PredisHandlerTest.php - - message: '#Call to an undefined method Michalsn\\CodeIgniterQueue\\Models\\QueueJobFailedModel::affectedRows\(\).#' + message: '#Call to an undefined method CodeIgniter\\Queue\\Models\\QueueJobFailedModel::truncate\(\).#' paths: - src/Handlers/BaseHandler.php - - message: '#Call to an undefined method Michalsn\\CodeIgniterQueue\\Models\\QueueJobFailedModel::truncate\(\).#' - paths: - - src/Handlers/BaseHandler.php - - - message: '#Parameter \#3 \$tries of method Michalsn\\CodeIgniterQueue\\Commands\\QueueWork::handleWork\(\) expects int\|null, string\|true\|null given.#' - paths: - - src/Commands/QueueWork.php - - - message: '#Parameter \#4 \$retryAfter of method Michalsn\\CodeIgniterQueue\\Commands\\QueueWork::handleWork\(\) expects int\|null, string\|true\|null given.#' - paths: - - src/Commands/QueueWork.php - - - message: '#Expression on left side of \?\? is not nullable.#' - paths: - - src/Commands/QueueWork.php - - - message: '#Variable \$job might not be defined.#' + message: '#If condition is always true.#' paths: - src/Commands/QueueWork.php universalObjectCratesClasses: @@ -73,3 +38,10 @@ parameters: - APP_NAMESPACE - CI_DEBUG - ENVIRONMENT + strictRules: + allRules: false + disallowedLooseComparison: true + booleansInConditions: true + disallowedConstructs: true + matchingInheritedMethodNames: true + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 470e815..48fe085 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,15 +1,10 @@ + cacheDirectory="build/.phpunit.cache" + beStrictAboutCoverageMetadata="true"> - - - ./src/ - - - ./src/Commands - ./src/Config - + @@ -43,27 +32,11 @@ - - - - - 0.50 - - - 30 - - - 2 - - - true - - - true - - - - + + + + + @@ -99,4 +72,14 @@ --> + + + ./src/ + + + ./src/Commands/Generators + ./src/Commands/Utils + ./src/Config + + diff --git a/rector.php b/rector.php index 6c3c25a..cec36f6 100644 --- a/rector.php +++ b/rector.php @@ -1,7 +1,10 @@ sets([ @@ -59,9 +67,10 @@ realpath(getcwd()) . '/vendor/codeigniter4/framework/system/Test/bootstrap.php', ]); - if (is_file(__DIR__ . '/phpstan.neon.dist')) { - $rectorConfig->phpstanConfig(__DIR__ . '/phpstan.neon.dist'); - } + $rectorConfig->phpstanConfigs([ + __DIR__ . '/phpstan.neon.dist', + __DIR__ . '/vendor/phpstan/phpstan-strict-rules/rules.neon', + ]); // Set the target version for refactoring $rectorConfig->phpVersion(PhpVersion::PHP_81); @@ -73,17 +82,22 @@ $rectorConfig->skip([ __DIR__ . '/app/Views', - JsonThrowOnErrorRector::class, StringifyStrNeedlesRector::class, + YieldDataProviderRector::class, // Note: requires php 8 RemoveUnusedPromotedPropertyRector::class, + AnnotationWithValueToAttributeRector::class, // May load view files directly when detecting classes StringClassNameToClassConstantRector::class, // Supported from PHPUnit 10 DataProviderAnnotationToAttributeRector::class, + + NewInInitializerRector::class => [ + 'src/Payloads/Payload.php', + ], ]); // auto import fully qualified class names @@ -108,10 +122,16 @@ $rectorConfig->rule(FuncGetArgsToVariadicParamRector::class); $rectorConfig->rule(MakeInheritedMethodVisibilitySameAsParentRector::class); $rectorConfig->rule(SimplifyEmptyArrayCheckRector::class); + $rectorConfig->rule(SimplifyEmptyCheckOnEmptyArrayRector::class); + $rectorConfig->rule(TernaryEmptyArrayArrayDimFetchToCoalesceRector::class); + $rectorConfig->rule(EmptyOnNullableObjectToInstanceOfRector::class); + $rectorConfig->rule(DisallowedEmptyRuleFixerRector::class); $rectorConfig ->ruleWithConfiguration(TypedPropertyFromAssignsRector::class, [ /** - * The INLINE_PUBLIC value is default to false to avoid BC break, if you use for libraries and want to preserve BC break, you don't need to configure it, as it included in LevelSetList::UP_TO_PHP_74 + * The INLINE_PUBLIC value is default to false to avoid BC break, + * if you use for libraries and want to preserve BC break, you don't + * need to configure it, as it included in LevelSetList::UP_TO_PHP_74 * Set to true for projects that allow BC break */ TypedPropertyFromAssignsRector::INLINE_PUBLIC => false, diff --git a/src/BaseJob.php b/src/BaseJob.php index ad67410..0538783 100644 --- a/src/BaseJob.php +++ b/src/BaseJob.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue; abstract class BaseJob diff --git a/src/Commands/Generators/JobGenerator.php b/src/Commands/Generators/JobGenerator.php index 2996335..4a067e2 100644 --- a/src/Commands/Generators/JobGenerator.php +++ b/src/Commands/Generators/JobGenerator.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Commands\Generators; use CodeIgniter\CLI\BaseCommand; @@ -43,7 +54,7 @@ class JobGenerator extends BaseCommand /** * The Command's Arguments * - * @var array + * @var array */ protected $arguments = [ 'name' => 'The job class name.', @@ -52,7 +63,7 @@ class JobGenerator extends BaseCommand /** * The Command's Options * - * @var array + * @var array */ protected $options = [ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', diff --git a/src/Commands/QueueClear.php b/src/Commands/QueueClear.php index 5d8e175..c57ba24 100644 --- a/src/Commands/QueueClear.php +++ b/src/Commands/QueueClear.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Commands; use CodeIgniter\CLI\BaseCommand; @@ -38,7 +49,7 @@ class QueueClear extends BaseCommand /** * The Command's Arguments * - * @var array + * @var array */ protected $arguments = [ 'queueName' => 'Name of the queue we will work with.', @@ -50,7 +61,8 @@ class QueueClear extends BaseCommand public function run(array $params) { // Read params - if (! $queue = array_shift($params)) { + $queue = array_shift($params); + if ($queue === null) { CLI::error('The queueName is not specified.'); return EXIT_ERROR; diff --git a/src/Commands/QueueFailed.php b/src/Commands/QueueFailed.php index 3968095..9d7d2a1 100644 --- a/src/Commands/QueueFailed.php +++ b/src/Commands/QueueFailed.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Commands; use CodeIgniter\CLI\BaseCommand; @@ -39,7 +50,7 @@ class QueueFailed extends BaseCommand /** * The Command's Options * - * @var array + * @var array */ protected $options = [ '-queue' => 'Queue name.', @@ -54,7 +65,7 @@ public function run(array $params) $queue = $params['queue'] ?? CLI::getOption('queue'); /** @var QueueConfig $config */ - $config = config('queue'); + $config = config('Queue'); $results = service('queue')->listFailed($queue); @@ -62,7 +73,13 @@ public function run(array $params) $tbody = []; foreach ($results as $result) { - $tbody[] = [$result->id, $result->connection, $result->queue, $this->getClassName($result->payload['job'], $config), $result->failed_at]; + $tbody[] = [ + $result->id, + $result->connection, + $result->queue, + $this->getClassName($result->payload['job'], $config), + $result->failed_at, + ]; } CLI::table($tbody, $thead); diff --git a/src/Commands/QueueFlush.php b/src/Commands/QueueFlush.php index f1868b1..446dc11 100644 --- a/src/Commands/QueueFlush.php +++ b/src/Commands/QueueFlush.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Commands; use CodeIgniter\CLI\BaseCommand; @@ -38,7 +49,7 @@ class QueueFlush extends BaseCommand /** * The Command's Options * - * @var array + * @var array */ protected $options = [ '-hours' => 'Number of hours.', @@ -54,6 +65,10 @@ public function run(array $params) $hours = $params['hours'] ?? CLI::getOption('hours'); $queue = $params['queue'] ?? CLI::getOption('queue'); + if ($hours !== null) { + $hours = (int) $hours; + } + service('queue')->flush($hours, $queue); if ($hours === null) { diff --git a/src/Commands/QueueForget.php b/src/Commands/QueueForget.php index 7a02ee3..9f930fb 100644 --- a/src/Commands/QueueForget.php +++ b/src/Commands/QueueForget.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Commands; use CodeIgniter\CLI\BaseCommand; @@ -38,7 +49,7 @@ class QueueForget extends BaseCommand /** * The Command's Arguments * - * @var array + * @var array */ protected $arguments = [ 'id' => 'ID of the failed job.', @@ -50,13 +61,14 @@ class QueueForget extends BaseCommand public function run(array $params) { // Read params - if (! $id = array_shift($params)) { + $id = array_shift($params); + if ($id === null) { CLI::error('The ID of the failed job is not specified.'); return EXIT_ERROR; } - if (service('queue')->forget($id)) { + if (service('queue')->forget((int) $id)) { CLI::write(sprintf('Failed job with ID %s has been removed.', $id), 'green'); } else { CLI::write(sprintf('Could not find the failed job with ID %s', $id), 'red'); diff --git a/src/Commands/QueuePublish.php b/src/Commands/QueuePublish.php index 8167205..6e4e7bb 100644 --- a/src/Commands/QueuePublish.php +++ b/src/Commands/QueuePublish.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Commands; use CodeIgniter\CLI\BaseCommand; @@ -11,14 +22,11 @@ class QueuePublish extends BaseCommand { protected $group = 'Queue'; protected $name = 'queue:publish'; - protected $description = 'Publish QueueJob config file into the current application.'; + protected $description = 'Publish Queue config file into the current application.'; - /** - * @return void - */ - public function run(array $params) + public function run(array $params): void { - $source = service('autoloader')->getNamespace('Michalsn\\CodeIgniterQueue')[0]; + $source = service('autoloader')->getNamespace('CodeIgniter\\Queue')[0]; $publisher = new Publisher($source, APPPATH); @@ -34,8 +42,8 @@ public function run(array $params) foreach ($publisher->getPublished() as $file) { $contents = file_get_contents($file); - $contents = str_replace('namespace Michalsn\\CodeIgniterQueue\\Config', 'namespace Config', $contents); - $contents = str_replace('use CodeIgniter\\Config\\BaseConfig', 'use Michalsn\\CodeIgniterQueue\\Config\\Queue as BaseQueue', $contents); + $contents = str_replace('namespace CodeIgniter\\Queue\\Config', 'namespace Config', $contents); + $contents = str_replace('use CodeIgniter\\Config\\BaseConfig', 'use CodeIgniter\\Queue\\Config\\Queue as BaseQueue', $contents); $contents = str_replace('class Queue extends BaseConfig', 'class Queue extends BaseQueue', $contents); $method = <<<'EOT' @@ -50,6 +58,8 @@ public function __construct() /** * Resolve job class name. + * + * @return class-string */ public function resolveJobClass(string $name): string { diff --git a/src/Commands/QueueRetry.php b/src/Commands/QueueRetry.php index 6bbb43b..c269d53 100644 --- a/src/Commands/QueueRetry.php +++ b/src/Commands/QueueRetry.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Commands; use CodeIgniter\CLI\BaseCommand; @@ -38,7 +49,7 @@ class QueueRetry extends BaseCommand /** * The Command's Arguments * - * @var array + * @var array */ protected $arguments = [ 'id' => 'ID of the failed job or "all" for all failed jobs.', @@ -47,7 +58,7 @@ class QueueRetry extends BaseCommand /** * The Command's Options * - * @var array + * @var array */ protected $options = [ '-queue' => 'Queue name.', @@ -59,15 +70,14 @@ class QueueRetry extends BaseCommand public function run(array $params) { // Read params - if (! $id = array_shift($params)) { + $id = array_shift($params); + if ($id === null) { CLI::error('The ID of the failed job is not specified.'); return EXIT_ERROR; } - if ($id === 'all') { - $id = null; - } + $id = $id === 'all' ? null : (int) $id; $queue = $params['queue'] ?? CLI::getOption('queue'); diff --git a/src/Commands/QueueStop.php b/src/Commands/QueueStop.php index 91085d1..219db56 100644 --- a/src/Commands/QueueStop.php +++ b/src/Commands/QueueStop.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Commands; use CodeIgniter\CLI\BaseCommand; @@ -38,7 +49,7 @@ class QueueStop extends BaseCommand /** * The Command's Arguments * - * @var array + * @var array */ protected $arguments = [ 'queueName' => 'Name of the queue we will work with.', @@ -47,7 +58,7 @@ class QueueStop extends BaseCommand /** * The Command's Options * - * @var array + * @var array */ protected $options = [ ]; @@ -58,7 +69,8 @@ class QueueStop extends BaseCommand public function run(array $params) { // Read params - if (! $queue = array_shift($params)) { + $queue = array_shift($params); + if ($queue === null) { CLI::error('The queueName is not specified.'); return EXIT_ERROR; @@ -69,7 +81,7 @@ public function run(array $params) cache()->save($cacheName, $startTime, MINUTE * 10); - CLI::write('QueueJob will be stopped after the current job finish', 'yellow'); + CLI::write('Queue will be stopped after the current job finish', 'yellow'); return EXIT_SUCCESS; } diff --git a/src/Commands/QueueWork.php b/src/Commands/QueueWork.php index c19214b..4f2bf22 100644 --- a/src/Commands/QueueWork.php +++ b/src/Commands/QueueWork.php @@ -1,12 +1,24 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Commands; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; -use Exception; use CodeIgniter\Queue\Config\Queue as QueueConfig; use CodeIgniter\Queue\Entities\QueueJob; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use Exception; use Throwable; class QueueWork extends BaseCommand @@ -42,7 +54,7 @@ class QueueWork extends BaseCommand /** * The Command's Arguments * - * @var array + * @var array */ protected $arguments = [ 'queueName' => 'Name of the queue we will work with.', @@ -51,7 +63,7 @@ class QueueWork extends BaseCommand /** * The Command's Options * - * @var array + * @var array */ protected $options = [ '-sleep' => 'Wait time between the next check for available job when the queue is empty. Default value: 10 (seconds).', @@ -79,23 +91,34 @@ public function run(array $params) $stopWhenEmpty = false; $waiting = false; - // Read params - if (! $queue = array_shift($params)) { + // Read queue name from params + $queue = array_shift($params); + if ($queue === null) { CLI::error('The queueName is not specified.'); return EXIT_ERROR; } // Read options - $sleep = $params['sleep'] ?? CLI::getOption('sleep') ?? 10; - $rest = $params['rest'] ?? CLI::getOption('rest') ?? 0; - $maxJobs = $params['max-jobs'] ?? CLI::getOption('max-jobs') ?? 0; - $maxTime = $params['max-time'] ?? CLI::getOption('max-time') ?? 0; - $memory = $params['memory'] ?? CLI::getOption('memory') ?? 128; - $priority = $params['priority'] ?? CLI::getOption('priority') ?? $config->getQueuePriorities($queue) ?? 'default'; - $tries = $params['tries'] ?? CLI::getOption('tries'); - $retryAfter = $params['retry-after'] ?? CLI::getOption('retry-after'); - $countJobs = 0; + [ + $error, + $sleep, + $rest, + $maxJobs, + $maxTime, + $memory, + $priority, + $tries, + $retryAfter, + ] = $this->readOptions($params, $config, $queue); + + if ($error !== null) { + CLI::write($error, 'red'); + + return EXIT_ERROR; + } + + $countJobs = 0; if (array_key_exists('stop-when-empty', $params) || CLI::getOption('stop-when-empty')) { $stopWhenEmpty = true; @@ -111,7 +134,7 @@ public function run(array $params) CLI::write(PHP_EOL); - $priority = array_map('trim', explode(',', $priority)); + $priority = array_map('trim', explode(',', (string) $priority)); while (true) { $work = service('queue')->pop($queue, $priority); @@ -148,7 +171,7 @@ public function run(array $params) CLI::print('Starting a new job: ', 'cyan'); CLI::print($work->payload['job'], 'light_cyan'); CLI::print(', with ID: ', 'cyan'); - CLI::print($work->id, 'light_cyan'); + CLI::print((string) $work->id, 'light_cyan'); $this->handleWork($work, $config, $tries, $retryAfter); @@ -175,12 +198,56 @@ public function run(array $params) } } + private function readOptions(array $params, QueueConfig $config, string $queue): array + { + $options = [ + 'error' => null, + 'sleep' => $params['sleep'] ?? CLI::getOption('sleep') ?? 10, + 'rest' => $params['rest'] ?? CLI::getOption('rest') ?? 0, + 'maxJobs' => $params['max-jobs'] ?? CLI::getOption('max-jobs') ?? 0, + 'maxTime' => $params['max-time'] ?? CLI::getOption('max-time') ?? 0, + 'memory' => $params['memory'] ?? CLI::getOption('memory') ?? 128, + 'priority' => $params['priority'] ?? CLI::getOption('priority') ?? $config->getQueuePriorities($queue) ?? 'default', + 'tries' => $params['tries'] ?? CLI::getOption('tries'), + 'retryAfter' => $params['retry-after'] ?? CLI::getOption('retry-after'), + ]; + + // Options that, being defined, cannot be `true` + $keys = ['sleep', 'rest', 'maxJobs', 'maxTime', 'memory', 'priority', 'tries', 'retryAfter']; + + foreach ($keys as $key) { + if ($options[$key] === true) { + $options['error'] = sprintf('Option: "-%s" must have a defined value.', $key); + + return array_values($options); + } + } + // Options that, being defined, have to be `int` + $keys = array_diff($keys, ['priority']); + + foreach ($keys as $key) { + if ($options[$key] !== null && ! is_int($options[$key])) { + $options[$key] = (int) $options[$key]; + } + } + + return array_values($options); + } + private function handleWork(QueueJob $work, QueueConfig $config, ?int $tries, ?int $retryAfter): void { timer()->start('work'); $payload = $work->payload; + $payloadMetadata = null; + try { + // Load payload metadata + $payloadMetadata = PayloadMetadata::fromArray($payload['metadata'] ?? []); + + // Renew lock if needed + $this->renewLock($payloadMetadata); + $class = $config->resolveJobClass($payload['job']); $job = new $class($payload['data']); $job->process(); @@ -189,8 +256,11 @@ private function handleWork(QueueJob $work, QueueConfig $config, ?int $tries, ?i service('queue')->done($work, $config->keepDoneJobs); CLI::write('The processing of this job was successful', 'green'); + + // Check chained jobs + $this->processNextJobInChain($payloadMetadata); } catch (Throwable $err) { - if (isset($job) && ++$work->attempts < $tries ?? $job->getTries()) { + if (isset($job) && ++$work->attempts < ($tries ?? $job->getTries())) { // Schedule for later service('queue')->later($work, $retryAfter ?? $job->getRetryAfter()); } else { @@ -199,11 +269,83 @@ private function handleWork(QueueJob $work, QueueConfig $config, ?int $tries, ?i } CLI::write('The processing of this job failed', 'red'); } finally { + // Remove lock if needed + $this->clearLock($payloadMetadata); + timer()->stop('work'); CLI::write(sprintf('It took: %s sec', timer()->getElapsedTime('work')) . PHP_EOL, 'cyan'); } } + /** + * Process the next job in the chain + */ + private function processNextJobInChain(PayloadMetadata $payloadMetadata): void + { + if (! $payloadMetadata->hasChainedJobs()) { + return; + } + + $nextPayload = $payloadMetadata->getChainedJobs()->shift(); + $priority = $nextPayload->getPriority(); + $delay = $nextPayload->getDelay(); + + if ($priority !== null) { + service('queue')->setPriority($priority); + } + + if ($delay !== null) { + service('queue')->setDelay($delay); + } + + if ($payloadMetadata->hasChainedJobs()) { + $nextPayload->setChainedJobs($payloadMetadata->getChainedJobs()); + } + + service('queue')->push( + $nextPayload->getQueue(), + $nextPayload->getJob(), + $nextPayload->getData(), + $nextPayload->getMetadata(), + ); + + CLI::write(sprintf('Chained job: %s has been placed in the queue: %s', $nextPayload->getJob(), $nextPayload->getQueue()), 'green'); + } + + /** + * Renew task lock + */ + private function renewLock(PayloadMetadata $payloadMetadata): void + { + if (! $payloadMetadata->has('taskLockTTL') || ! $payloadMetadata->has('taskLockKey')) { + return; + } + + $ttl = $payloadMetadata->get('taskLockTTL'); + $key = $payloadMetadata->get('taskLockKey'); + + // Permanent lock, no need to renew + if ($ttl === 0) { + return; + } + + cache()->save($key, [], $ttl); + } + + /** + * Remove task lock + */ + private function clearLock(PayloadMetadata $payloadMetadata): void + { + if (! $payloadMetadata->has('taskLockKey')) { + return; + } + + $key = $payloadMetadata->get('taskLockKey'); + + cache()->delete($key); + } + private function maxJobsCheck(int $maxJobs, int $countJobs): bool { if ($maxJobs > 0 && $countJobs >= $maxJobs) { diff --git a/src/Config/Queue.php b/src/Config/Queue.php index 7241578..3a5ac76 100644 --- a/src/Config/Queue.php +++ b/src/Config/Queue.php @@ -1,12 +1,26 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Config; use CodeIgniter\Config\BaseConfig; use CodeIgniter\Queue\Exceptions\QueueException; use CodeIgniter\Queue\Handlers\DatabaseHandler; use CodeIgniter\Queue\Handlers\PredisHandler; +use CodeIgniter\Queue\Handlers\RabbitMQHandler; use CodeIgniter\Queue\Handlers\RedisHandler; +use CodeIgniter\Queue\Interfaces\JobInterface; +use CodeIgniter\Queue\Interfaces\QueueInterface; class Queue extends BaseConfig { @@ -17,11 +31,14 @@ class Queue extends BaseConfig /** * Available handlers. + * + * @var array> */ public array $handlers = [ 'database' => DatabaseHandler::class, 'redis' => RedisHandler::class, 'predis' => PredisHandler::class, + 'rabbitmq' => RabbitMQHandler::class, ]; /** @@ -30,6 +47,9 @@ class Queue extends BaseConfig public array $database = [ 'dbGroup' => 'default', 'getShared' => true, + // use skip locked feature to maintain concurrency calls + // this is not relevant for the SQLite3 database driver + 'skipLocked' => true, ]; /** @@ -57,6 +77,17 @@ class Queue extends BaseConfig 'prefix' => '', ]; + /** + * RabbitMQ handler config. + */ + public array $rabbitmq = [ + 'host' => '127.0.0.1', + 'port' => 5672, + 'user' => 'guest', + 'password' => 'guest', + 'vhost' => '/', + ]; + /** * Whether to keep the DONE jobs in the queue. */ @@ -81,6 +112,8 @@ class Queue extends BaseConfig /** * Your jobs handlers. + * + * @var array> */ public array $jobHandlers = []; @@ -95,6 +128,8 @@ public function __construct() /** * Resolve job class name. + * + * @return class-string */ public function resolveJobClass(string $name): string { diff --git a/src/Config/Registrar.php b/src/Config/Registrar.php index 3f40e64..634837b 100644 --- a/src/Config/Registrar.php +++ b/src/Config/Registrar.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Config; class Registrar diff --git a/src/Config/Services.php b/src/Config/Services.php index 3624e46..1ca828f 100644 --- a/src/Config/Services.php +++ b/src/Config/Services.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Config; use CodeIgniter\Config\BaseService; @@ -9,14 +20,14 @@ class Services extends BaseService { - public static function queue(?QueueConfig $config = null, $getShared = true): QueueInterface + public static function queue($getShared = true): QueueInterface { if ($getShared) { - return static::getSharedInstance('queue', $config); + return static::getSharedInstance('queue'); } /** @var QueueConfig $config */ - $config ??= config('Queue'); + $config = config('Queue'); return (new Queue($config))->init(); } diff --git a/src/Database/Migrations/2023-10-12-112040_AddQueueTables.php b/src/Database/Migrations/2023-10-12-112040_AddQueueTables.php index 3e80440..e593047 100644 --- a/src/Database/Migrations/2023-10-12-112040_AddQueueTables.php +++ b/src/Database/Migrations/2023-10-12-112040_AddQueueTables.php @@ -1,12 +1,23 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Database\Migrations; use CodeIgniter\Database\Migration; class AddQueueTables extends Migration { - public function up() + public function up(): void { $this->forge->addField([ 'id' => ['type' => 'bigint', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], @@ -34,7 +45,7 @@ public function up() $this->forge->createTable('queue_jobs_failed', true); } - public function down() + public function down(): void { $this->forge->dropTable('queue_jobs', true); $this->forge->dropTable('queue_jobs_failed', true); diff --git a/src/Database/Migrations/2023-11-05-064053_AddPriorityField.php b/src/Database/Migrations/2023-11-05-064053_AddPriorityField.php index f437963..82089b0 100644 --- a/src/Database/Migrations/2023-11-05-064053_AddPriorityField.php +++ b/src/Database/Migrations/2023-11-05-064053_AddPriorityField.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Database\Migrations; use CodeIgniter\Database\BaseConnection; @@ -10,7 +21,7 @@ */ class AddPriorityField extends Migration { - public function up() + public function up(): void { $fields = [ 'priority' => [ @@ -40,7 +51,7 @@ public function up() $this->forge->processIndexes('queue_jobs'); } - public function down() + public function down(): void { // Ugly fix for dropping the correct index $keys = $this->db->getIndexData('queue_jobs'); diff --git a/src/Database/Migrations/2024-12-27-110712_ChangePayloadFieldTypeInSqlsrv.php b/src/Database/Migrations/2024-12-27-110712_ChangePayloadFieldTypeInSqlsrv.php new file mode 100644 index 0000000..c77c4e0 --- /dev/null +++ b/src/Database/Migrations/2024-12-27-110712_ChangePayloadFieldTypeInSqlsrv.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Database\Migrations; + +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Migration; + +/** + * @property BaseConnection $db + */ +class ChangePayloadFieldTypeInSqlsrv extends Migration +{ + public function up(): void + { + if ($this->db->DBDriver === 'SQLSRV') { + $fields = [ + 'payload' => [ + 'name' => 'payload', + 'type' => 'NVARCHAR', + 'constraint' => 'MAX', + 'null' => false, + ], + ]; + $this->forge->modifyColumn('queue_jobs', $fields); + } + } + + public function down(): void + { + if ($this->db->DBDriver === 'SQLSRV') { + $fields = [ + 'payload' => [ + 'name' => 'payload', + 'type' => 'TEXT', // already deprecated + 'null' => false, + ], + ]; + $this->forge->modifyColumn('queue_jobs', $fields); + } + } +} diff --git a/src/Entities/QueueJob.php b/src/Entities/QueueJob.php index 4d79d97..670a9f8 100644 --- a/src/Entities/QueueJob.php +++ b/src/Entities/QueueJob.php @@ -1,9 +1,31 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Entities; use CodeIgniter\Entity\Entity; +use CodeIgniter\I18n\Time; +/** + * @property int $attempts + * @property Time $available_at + * @property Time $created_at + * @property int $id + * @property array $payload + * @property string $priority + * @property string $queue + * @property int $status + */ class QueueJob extends Entity { protected $dates = ['available_at', 'created_at']; diff --git a/src/Entities/QueueJobFailed.php b/src/Entities/QueueJobFailed.php index 745a6c8..6b296d9 100644 --- a/src/Entities/QueueJobFailed.php +++ b/src/Entities/QueueJobFailed.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Entities; use CodeIgniter\Entity\Entity; diff --git a/src/Enums/Status.php b/src/Enums/Status.php index d1c4853..eaacfc9 100644 --- a/src/Enums/Status.php +++ b/src/Enums/Status.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Enums; enum Status: int diff --git a/src/Exceptions/QueueException.php b/src/Exceptions/QueueException.php index ca208da..321fd2d 100644 --- a/src/Exceptions/QueueException.php +++ b/src/Exceptions/QueueException.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Exceptions; use RuntimeException; @@ -40,4 +51,14 @@ public static function forIncorrectQueuePriority(string $priority, string $queue { return new self(lang('Queue.incorrectQueuePriority', [$priority, $queue])); } + + public static function forIncorrectDelayValue(): static + { + return new self(lang('Queue.incorrectDelayValue')); + } + + public static function forFailedJsonEncode(string $error): static + { + return new self(lang('Queue.failedToJsonEncode', [$error])); + } } diff --git a/src/Handlers/BaseHandler.php b/src/Handlers/BaseHandler.php index b6c3a35..f364802 100644 --- a/src/Handlers/BaseHandler.php +++ b/src/Handlers/BaseHandler.php @@ -1,13 +1,29 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Handlers; +use Closure; use CodeIgniter\I18n\Time; use CodeIgniter\Queue\Config\Queue as QueueConfig; use CodeIgniter\Queue\Entities\QueueJob; use CodeIgniter\Queue\Entities\QueueJobFailed; use CodeIgniter\Queue\Exceptions\QueueException; use CodeIgniter\Queue\Models\QueueJobFailedModel; +use CodeIgniter\Queue\Payloads\ChainBuilder; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use CodeIgniter\Queue\QueuePushResult; +use CodeIgniter\Queue\Traits\HasQueueValidation; use ReflectionException; use Throwable; @@ -16,26 +32,25 @@ */ abstract class BaseHandler { + use HasQueueValidation; + protected QueueConfig $config; protected ?string $priority = null; + protected ?int $delay = null; - /** - * Set priority for job queue. - */ - public function setPriority(string $priority): static - { - if (! preg_match('/^[a-z_-]+$/', $priority)) { - throw QueueException::forIncorrectPriorityFormat(); - } + abstract public function name(): string; - if (strlen($priority) > 64) { - throw QueueException::forTooLongPriorityName(); - } + abstract public function push(string $queue, string $job, array $data, ?PayloadMetadata $metadata = null): QueuePushResult; - $this->priority = $priority; + abstract public function pop(string $queue, array $priorities): ?QueueJob; - return $this; - } + abstract public function later(QueueJob $queueJob, int $seconds): bool; + + abstract public function failed(QueueJob $queueJob, Throwable $err, bool $keepJob): bool; + + abstract public function done(QueueJob $queueJob, bool $keepJob): bool; + + abstract public function clear(?string $queue = null): bool; /** * Retry failed job. @@ -47,11 +62,11 @@ public function retry(?int $id, ?string $queue): int $jobs = model(QueueJobFailedModel::class) ->when( $id !== null, - static fn ($query) => $query->where('id', $id) + static fn ($query) => $query->where('id', $id), ) ->when( $queue !== null, - static fn ($query) => $query->where('queue', $queue) + static fn ($query) => $query->where('queue', $queue), ) ->findAll(); @@ -64,7 +79,7 @@ public function retry(?int $id, ?string $queue): int } /** - * Delete failed job by ID. + * Delete a failed job by ID. */ public function forget(int $id): bool { @@ -87,11 +102,11 @@ public function flush(?int $hours, ?string $queue): bool return model(QueueJobFailedModel::class) ->when( $hours !== null, - static fn ($query) => $query->where('failed_at <=', Time::now()->subHours($hours)->timestamp) + static fn ($query) => $query->where('failed_at <=', Time::now()->subHours($hours)->timestamp), ) ->when( $queue !== null, - static fn ($query) => $query->where('queue', $queue) + static fn ($query) => $query->where('queue', $queue), ) ->delete(); } @@ -104,12 +119,49 @@ public function listFailed(?string $queue): array return model(QueueJobFailedModel::class) ->when( $queue !== null, - static fn ($query) => $query->where('queue', $queue) + static fn ($query) => $query->where('queue', $queue), ) ->orderBy('failed_at', 'desc') ->findAll(); } + /** + * Set delay for job queue (in seconds). + */ + public function setDelay(int $delay): static + { + $this->validateDelay($delay); + + $this->delay = $delay; + + return $this; + } + + /** + * Set priority for job queue. + */ + public function setPriority(string $priority): static + { + $this->validatePriority($priority); + + $this->priority = $priority; + + return $this; + } + + /** + * Create a job chain on the specified queue + * + * @param Closure $callback Chain definition callback + */ + public function chain(Closure $callback): QueuePushResult + { + $chainBuilder = new ChainBuilder($this); + $callback($chainBuilder); + + return $chainBuilder->dispatch(); + } + /** * Log failed job. * @@ -121,7 +173,7 @@ protected function logFailed(QueueJob $queueJob, Throwable $err): bool "file: {$err->getFile()}:{$err->getLine()}"; $queueJobFailed = new QueueJobFailed([ - 'connection' => 'database', + 'connection' => $this->name(), 'queue' => $queueJob->queue, 'payload' => $queueJob->payload, 'priority' => $queueJob->priority, diff --git a/src/Handlers/DatabaseHandler.php b/src/Handlers/DatabaseHandler.php index e70ae43..403e7cd 100644 --- a/src/Handlers/DatabaseHandler.php +++ b/src/Handlers/DatabaseHandler.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Handlers; use CodeIgniter\I18n\Time; @@ -8,7 +19,9 @@ use CodeIgniter\Queue\Enums\Status; use CodeIgniter\Queue\Interfaces\QueueInterface; use CodeIgniter\Queue\Models\QueueJobModel; -use CodeIgniter\Queue\Payload; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use CodeIgniter\Queue\QueuePushResult; use ReflectionException; use Throwable; @@ -22,27 +35,43 @@ public function __construct(protected QueueConfig $config) $this->jobModel = model(QueueJobModel::class, true, $connection); } + /** + * Name of the handler. + */ + public function name(): string + { + return 'database'; + } + /** * Add job to the queue. - * - * @throws ReflectionException */ - public function push(string $queue, string $job, array $data): bool + public function push(string $queue, string $job, array $data, ?PayloadMetadata $metadata = null): QueuePushResult { $this->validateJobAndPriority($queue, $job); $queueJob = new QueueJob([ 'queue' => $queue, - 'payload' => new Payload($job, $data), + 'payload' => new Payload($job, $data, $metadata), 'priority' => $this->priority, 'status' => Status::PENDING->value, 'attempts' => 0, - 'available_at' => Time::now()->timestamp, + 'available_at' => Time::now()->addSeconds($this->delay ?? 0), ]); - $this->priority = null; + $this->priority = $this->delay = null; + + try { + $jobId = $this->jobModel->insert($queueJob); + } catch (Throwable $e) { + return QueuePushResult::failure($e->getMessage()); + } + + if ($jobId === 0) { + return QueuePushResult::failure('Failed to insert job into the database.'); + } - return $this->jobModel->insert($queueJob, false); + return QueuePushResult::success($jobId); } /** @@ -73,7 +102,7 @@ public function pop(string $queue, array $priorities): ?QueueJob public function later(QueueJob $queueJob, int $seconds): bool { $queueJob->status = Status::PENDING->value; - $queueJob->available_at = Time::now()->addSeconds($seconds)->timestamp; + $queueJob->available_at = Time::now()->addSeconds($seconds); return $this->jobModel->save($queueJob); } diff --git a/src/Handlers/PredisHandler.php b/src/Handlers/PredisHandler.php index 2746b19..22be7cf 100644 --- a/src/Handlers/PredisHandler.php +++ b/src/Handlers/PredisHandler.php @@ -1,56 +1,97 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Handlers; +use CodeIgniter\Autoloader\FileLocator; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; -use Exception; use CodeIgniter\Queue\Config\Queue as QueueConfig; use CodeIgniter\Queue\Entities\QueueJob; use CodeIgniter\Queue\Enums\Status; use CodeIgniter\Queue\Interfaces\QueueInterface; -use CodeIgniter\Queue\Payload; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use CodeIgniter\Queue\QueuePushResult; +use Exception; use Predis\Client; use Throwable; class PredisHandler extends BaseHandler implements QueueInterface { private readonly Client $predis; + private readonly string $luaScript; public function __construct(protected QueueConfig $config) { try { $this->predis = new Client($config->predis, ['prefix' => $config->predis['prefix']]); $this->predis->time(); + + $locator = new FileLocator(service('autoloader')); + $luaScript = $locator->locateFile('CodeIgniter\Queue\Lua\pop_task', null, 'lua'); + if ($luaScript === false) { + throw new CriticalError('Queue: LUA script for Predis is not available.'); + } + $this->luaScript = file_get_contents($luaScript); } catch (Exception $e) { throw new CriticalError('Queue: Predis connection refused (' . $e->getMessage() . ').'); } } + /** + * Name of the handler. + */ + public function name(): string + { + return 'predis'; + } + /** * Add job to the queue. */ - public function push(string $queue, string $job, array $data): bool + public function push(string $queue, string $job, array $data, ?PayloadMetadata $metadata = null): QueuePushResult { $this->validateJobAndPriority($queue, $job); helper('text'); + $jobId = (int) random_string('numeric', 16); + $availableAt = Time::now()->addSeconds($this->delay ?? 0); + $queueJob = new QueueJob([ - 'id' => random_string('numeric', 16), + 'id' => $jobId, 'queue' => $queue, - 'payload' => new Payload($job, $data), + 'payload' => new Payload($job, $data, $metadata), 'priority' => $this->priority, 'status' => Status::PENDING->value, 'attempts' => 0, - 'available_at' => Time::now()->timestamp, + 'available_at' => $availableAt, ]); - $result = $this->predis->zadd("queues:{$queue}:{$this->priority}", [json_encode($queueJob) => Time::now()->timestamp]); + try { + $result = $this->predis->zadd("queues:{$queue}:{$this->priority}", [json_encode($queueJob) => $availableAt->timestamp]); + } catch (Throwable $e) { + return QueuePushResult::failure('Unexpected Redis error: ' . $e->getMessage()); + } finally { + $this->priority = $this->delay = null; + } - $this->priority = null; + $this->priority = $this->delay = null; - return $result > 0; + return $result > 0 + ? QueuePushResult::success($jobId) + : QueuePushResult::failure('Job already exists in the queue.'); } /** @@ -58,29 +99,29 @@ public function push(string $queue, string $job, array $data): bool */ public function pop(string $queue, array $priorities): ?QueueJob { - $tasks = []; - $now = Time::now()->timestamp; - - foreach ($priorities as $priority) { - if ($tasks = $this->predis->zrangebyscore("queues:{$queue}:{$priority}", '-inf', $now, ['LIMIT' => [0, 1]])) { - if ($this->predis->zrem("queues:{$queue}:{$priority}", ...$tasks)) { - break; - } - $tasks = []; - } - } + $now = (string) Time::now()->timestamp; + + // Prepare the arguments for the Lua script + $args = [ + 'queues:' . $queue, // KEYS[1] + $now, // ARGV[2] + json_encode($priorities), // ARGV[3] + ]; - if (empty($tasks[0])) { + // Execute the Lua script + $task = $this->predis->eval($this->luaScript, 1, ...$args); + + if ($task === null) { return null; } - $queueJob = new QueueJob(json_decode((string) $tasks[0], true)); + $queueJob = new QueueJob(json_decode((string) $task, true)); // Set the actual status as in DB. $queueJob->status = Status::RESERVED->value; $queueJob->syncOriginal(); - $this->predis->hset("queues:{$queue}::reserved", $queueJob->id, json_encode($queueJob)); + $this->predis->hset("queues:{$queue}::reserved", (string) $queueJob->id, json_encode($queueJob)); return $queueJob; } @@ -91,10 +132,14 @@ public function pop(string $queue, array $priorities): ?QueueJob public function later(QueueJob $queueJob, int $seconds): bool { $queueJob->status = Status::PENDING->value; - $queueJob->available_at = Time::now()->addSeconds($seconds)->timestamp; - - if ($result = $this->predis->zadd("queues:{$queueJob->queue}:{$queueJob->priority}", [json_encode($queueJob) => $queueJob->available_at->timestamp])) { - $this->predis->hdel("queues:{$queueJob->queue}::reserved", $queueJob->id); + $queueJob->available_at = Time::now()->addSeconds($seconds); + + $result = $this->predis->zadd( + "queues:{$queueJob->queue}:{$queueJob->priority}", + [json_encode($queueJob) => $queueJob->available_at->timestamp], + ); + if ($result !== 0) { + $this->predis->hdel("queues:{$queueJob->queue}::reserved", [$queueJob->id]); } return $result > 0; @@ -109,7 +154,7 @@ public function failed(QueueJob $queueJob, Throwable $err, bool $keepJob): bool $this->logFailed($queueJob, $err); } - return (bool) $this->predis->hdel("queues:{$queueJob->queue}::reserved", $queueJob->id); + return (bool) $this->predis->hdel("queues:{$queueJob->queue}::reserved", [$queueJob->id]); } /** @@ -122,7 +167,7 @@ public function done(QueueJob $queueJob, bool $keepJob): bool $this->predis->lpush("queues:{$queueJob->queue}::done", [json_encode($queueJob)]); } - return (bool) $this->predis->hdel("queues:{$queueJob->queue}::reserved", $queueJob->id); + return (bool) $this->predis->hdel("queues:{$queueJob->queue}::reserved", [$queueJob->id]); } /** @@ -131,14 +176,16 @@ public function done(QueueJob $queueJob, bool $keepJob): bool public function clear(?string $queue = null): bool { if ($queue !== null) { - if ($keys = $this->predis->keys("queues:{$queue}:*")) { + $keys = $this->predis->keys("queues:{$queue}:*"); + if ($keys !== []) { return $this->predis->del($keys) > 0; } return true; } - if ($keys = $this->predis->keys('queues:*')) { + $keys = $this->predis->keys('queues:*'); + if ($keys !== []) { return $this->predis->del($keys) > 0; } diff --git a/src/Handlers/RabbitMQHandler.php b/src/Handlers/RabbitMQHandler.php new file mode 100644 index 0000000..9f67bc0 --- /dev/null +++ b/src/Handlers/RabbitMQHandler.php @@ -0,0 +1,479 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Handlers; + +use CodeIgniter\Exceptions\CriticalError; +use CodeIgniter\I18n\Time; +use CodeIgniter\Queue\Config\Queue as QueueConfig; +use CodeIgniter\Queue\Entities\QueueJob; +use CodeIgniter\Queue\Enums\Status; +use CodeIgniter\Queue\Exceptions\QueueException; +use CodeIgniter\Queue\Interfaces\QueueInterface; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use CodeIgniter\Queue\QueuePushResult; +use PhpAmqpLib\Channel\AMQPChannel; +use PhpAmqpLib\Connection\AbstractConnection; +use PhpAmqpLib\Connection\AMQPConnectionConfig; +use PhpAmqpLib\Connection\AMQPConnectionFactory; +use PhpAmqpLib\Message\AMQPMessage; +use PhpAmqpLib\Wire\AMQPTable; +use Throwable; + +class RabbitMQHandler extends BaseHandler implements QueueInterface +{ + private readonly AbstractConnection $connection; + private readonly AMQPChannel $channel; + private array $declaredQueues = []; + private array $declaredExchanges = []; + + public function __construct(protected QueueConfig $config) + { + try { + $amqp = new AMQPConnectionConfig(); + $amqp->setHost($config->rabbitmq['host']); + $amqp->setPort($config->rabbitmq['port']); + $amqp->setUser($config->rabbitmq['user']); + $amqp->setPassword($config->rabbitmq['password']); + $amqp->setVhost($config->rabbitmq['vhost'] ?? '/'); + + // Enable SSL/TLS + if ($config->rabbitmq['ssl'] ?? ($config->rabbitmq['port'] === 5671)) { + $amqp->setIsSecure(true); + } + + $this->connection = AMQPConnectionFactory::create($amqp); + $this->channel = $this->connection->channel(); + + // Set QoS for consumer (prefetch limit) + $this->channel->basic_qos(0, 1, false); + + // Enable publisher confirms if configured + if ($config->rabbitmq['publisherConfirms'] ?? false) { + $this->channel->confirm_select(); + } + + // Register return handler for unroutable messages + $this->channel->set_return_listener(static function ($replyCode, $replyText, $exchange, $routingKey, $properties, $body): void { + log_message('error', "RabbitMQ returned unroutable message: {$replyCode} {$replyText} exchange={$exchange} routing_key={$routingKey}"); + }); + } catch (Throwable $e) { + throw new CriticalError('Queue: RabbitMQ connection failed. ' . $e->getMessage()); + } + } + + public function __destruct() + { + try { + $this->channel->close(); + $this->connection->close(); + } catch (Throwable) { + // Ignore connection cleanup errors + } + } + + /** + * Name of the handler. + */ + public function name(): string + { + return 'rabbitmq'; + } + + /** + * Add job to the queue. + */ + public function push(string $queue, string $job, array $data, ?PayloadMetadata $metadata = null): QueuePushResult + { + $this->validateJobAndPriority($queue, $job); + + try { + helper('text'); + $jobId = (int) random_string('numeric', 16); + + $queueJob = new QueueJob([ + 'id' => $jobId, + 'queue' => $queue, + 'payload' => new Payload($job, $data, $metadata), + 'priority' => $this->priority, + 'status' => Status::PENDING->value, + 'attempts' => 0, + 'available_at' => Time::now()->addSeconds($this->delay ?? 0), + ]); + + $this->declareQueue($queue); + $this->declareExchange($queue); + + $routingKey = $this->getRoutingKey($queue, $this->priority); + + if ($this->delay !== null && $this->delay > 0) { + // Calculate delay based on available_at time using consistent time source + $targetTime = $queueJob->available_at->getTimestamp(); + $currentTime = Time::now()->getTimestamp(); + $realDelay = $targetTime - $currentTime; + + if ($realDelay <= 0) { + // No delay needed or already past due - publish immediately + $message = $this->createMessage($queueJob); + $this->publishMessage($queue, $message, $routingKey); + } else { + // Use TTL + dead letter pattern for actual delays + $this->publishDelayedMessage($queue, $queueJob, $routingKey, $realDelay); + } + } else { + $message = $this->createMessage($queueJob); + $this->publishMessage($queue, $message, $routingKey); + } + + $this->priority = $this->delay = null; + + return QueuePushResult::success($jobId); + } catch (Throwable $e) { + return QueuePushResult::failure($e->getMessage()); + } + } + + /** + * Get next job from queue. + */ + public function pop(string $queue, array $priorities): ?QueueJob + { + try { + $this->declareQueue($queue); + + // Try to get message with priorities in order + foreach ($priorities as $priority) { + $queueName = $this->getQueueName($queue, $priority); + $message = $this->channel->basic_get($queueName, false); + + if ($message !== null) { + return $this->messageToQueueJob($message); + } + } + + return null; + } catch (Throwable $e) { + log_message('error', 'RabbitMQ pop error: ' . $e->getMessage()); + + return null; + } + } + + /** + * Reschedule job to run later. + */ + public function later(QueueJob $queueJob, int $seconds): bool + { + try { + $queueJob->status = Status::PENDING->value; + $queueJob->available_at = Time::now()->addSeconds($seconds); + + // Reject the original message without requeue + if (isset($queueJob->amqpDeliveryTag)) { + $this->channel->basic_nack($queueJob->amqpDeliveryTag, false, false); + } + + $routingKey = $this->getRoutingKey($queueJob->queue, $queueJob->priority); + + $this->publishDelayedMessage($queueJob->queue, $queueJob, $routingKey, $seconds); + + return true; + } catch (Throwable $e) { + log_message('error', 'RabbitMQ later error: ' . $e->getMessage()); + + return false; + } + } + + /** + * Handle failed job. + */ + public function failed(QueueJob $queueJob, Throwable $err, bool $keepJob): bool + { + try { + // Reject the message without requeue + if (isset($queueJob->amqpDeliveryTag)) { + $this->channel->basic_nack($queueJob->amqpDeliveryTag, false, false); + } + + if ($keepJob) { + $this->logFailed($queueJob, $err); + } + + return true; + } catch (Throwable $e) { + log_message('error', 'RabbitMQ failed error: ' . $e->getMessage()); + + return false; + } + } + + /** + * Mark job as completed. + */ + public function done(QueueJob $queueJob, bool $keepJob): bool + { + try { + // Acknowledge the message to remove it from the queue + if (isset($queueJob->amqpDeliveryTag)) { + $this->channel->basic_ack($queueJob->amqpDeliveryTag); + } + + if ($keepJob) { + // For RabbitMQ, we don't need to persist completed jobs anywhere + // as the message is already acknowledged and removed from the queue + // @TODO remove the $keepDoneJobs option entirely + $queueJob->status = Status::DONE->value; + } + + return true; + } catch (Throwable $e) { + log_message('error', 'RabbitMQ done error: ' . $e->getMessage()); + + return false; + } + } + + /** + * Clear all jobs from queue(s). + */ + public function clear(?string $queue = null): bool + { + try { + if ($queue === null) { + // Clear all configured queues + foreach (array_keys($this->config->queuePriorities) as $queueName) { + $this->clearQueue($queueName); + } + } else { + $this->clearQueue($queue); + } + + return true; + } catch (Throwable $e) { + log_message('error', 'RabbitMQ clear error: ' . $e->getMessage()); + + return false; + } + } + + /** + * Declare queue with priority support. + */ + private function declareQueue(string $queue): void + { + $priorities = $this->config->queuePriorities[$queue] ?? ['default']; + + foreach ($priorities as $priority) { + $queueName = $this->getQueueName($queue, $priority); + + if (! isset($this->declaredQueues[$queueName])) { + $this->channel->queue_declare( + $queueName, + false, + true, + false, + false, + ); + + $this->declaredQueues[$queueName] = true; + } + } + } + + /** + * Declare exchange for queue routing. + */ + private function declareExchange(string $queue): void + { + $exchangeName = $this->getExchangeName($queue); + + if (! isset($this->declaredExchanges[$exchangeName])) { + $this->channel->exchange_declare( + $exchangeName, + 'direct', + false, + true, + false, + ); + + $this->declaredExchanges[$exchangeName] = true; + } + + // Bind queues to exchanges + $priorities = $this->config->queuePriorities[$queue] ?? ['default']; + + foreach ($priorities as $priority) { + $queueName = $this->getQueueName($queue, $priority); + $routingKey = $this->getRoutingKey($queue, $priority); + + $this->channel->queue_bind($queueName, $exchangeName, $routingKey); + } + } + + /** + * Create AMQP message from QueueJob with optional additional properties. + */ + private function createMessage(QueueJob $queueJob, array $additionalProperties = []): AMQPMessage + { + $body = json_encode($queueJob->toArray()); + if ($body === false) { + throw QueueException::forFailedJsonEncode(json_last_error_msg()); + } + + $properties = array_merge([ + 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, + 'timestamp' => Time::now()->getTimestamp(), + 'content_type' => 'application/json', + 'message_id' => (string) $queueJob->id, + ], $additionalProperties); + + return new AMQPMessage($body, $properties); + } + + /** + * Convert AMQP message to QueueJob. + */ + private function messageToQueueJob(AMQPMessage $message): QueueJob + { + $data = json_decode($message->getBody(), true); + + $queueJob = new QueueJob($data); + + // Mark message as acknowledged but not deleted yet + // We'll ack it when done() is called + $queueJob->amqpDeliveryTag = $message->getDeliveryTag(); + + // Update the job status + $queueJob->status = Status::RESERVED->value; + $queueJob->syncOriginal(); + + return $queueJob; + } + + /** + * Publish message with delay using per-message TTL + dead letter pattern. + */ + private function publishDelayedMessage(string $queue, QueueJob $queueJob, string $routingKey, int $delaySeconds): void + { + $delayQueueName = $this->getDelayQueueName($queue); + $exchangeName = $this->getExchangeName($queue); // Use the main exchange + + // Declare single delay queue (without queue-level TTL) + if (! isset($this->declaredQueues[$delayQueueName])) { + $this->channel->queue_declare( + $delayQueueName, + false, + true, + false, + false, + false, + new AMQPTable([ + 'x-dead-letter-exchange' => $exchangeName, + 'x-dead-letter-routing-key' => $routingKey, + ]), + ); + + $this->declaredQueues[$delayQueueName] = true; + } + + // Bind delay queue to main exchange with delay routing key + $this->channel->queue_bind($delayQueueName, $exchangeName, $delayQueueName); + + // Create message with per-message expiration (milliseconds string) + $delayedMessage = $this->createMessage($queueJob, [ + 'expiration' => (string) ($delaySeconds * 1000), + ]); + + $this->publishWithOptionalConfirm($delayedMessage, $exchangeName, $delayQueueName); + } + + /** + * Publish message immediately. + */ + private function publishMessage(string $queue, AMQPMessage $message, string $routingKey): void + { + $exchangeName = $this->getExchangeName($queue); + $this->publishWithOptionalConfirm($message, $exchangeName, $routingKey); + } + + /** + * Publish message with optional publisher confirms and mandatory delivery. + */ + private function publishWithOptionalConfirm(AMQPMessage $message, string $exchange, string $routingKey): void + { + // Publish with mandatory=true to prevent silent drops if routing fails + $this->channel->basic_publish($message, $exchange, $routingKey, true); + + if ($this->config->rabbitmq['publisherConfirms'] ?? false) { + try { + $this->channel->wait_for_pending_acks_returns(); + } catch (Throwable $e) { + log_message('error', 'RabbitMQ publish confirm failure: ' . $e->getMessage()); + + throw $e; // Re-throw to fail the operation + } + } + } + + /** + * Clear the specific queue. + */ + private function clearQueue(string $queue): void + { + $priorities = $this->config->queuePriorities[$queue] ?? ['default']; + + foreach ($priorities as $priority) { + $queueName = $this->getQueueName($queue, $priority); + + try { + $this->channel->queue_purge($queueName); + } catch (Throwable) { + // Queue might not exist, ignore + } + } + } + + /** + * Get queue name with priority suffix. + */ + private function getQueueName(string $queue, string $priority): string + { + return $priority === 'default' ? $queue : "{$queue}_{$priority}"; + } + + /** + * Get exchange name for queue. + */ + private function getExchangeName(string $queue): string + { + return "queue_{$queue}_exchange"; + } + + /** + * Get delay queue name (single queue per logical queue). + */ + private function getDelayQueueName(string $queue): string + { + return "queue_{$queue}_delay"; + } + + /** + * Get routing key for priority. + */ + private function getRoutingKey(string $queue, string $priority): string + { + return "{$queue}.{$priority}"; + } +} diff --git a/src/Handlers/RedisHandler.php b/src/Handlers/RedisHandler.php index b3bd0f1..3fcd67f 100644 --- a/src/Handlers/RedisHandler.php +++ b/src/Handlers/RedisHandler.php @@ -1,14 +1,28 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Handlers; +use CodeIgniter\Autoloader\FileLocator; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; use CodeIgniter\Queue\Config\Queue as QueueConfig; use CodeIgniter\Queue\Entities\QueueJob; use CodeIgniter\Queue\Enums\Status; use CodeIgniter\Queue\Interfaces\QueueInterface; -use CodeIgniter\Queue\Payload; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use CodeIgniter\Queue\QueuePushResult; use Redis; use RedisException; use Throwable; @@ -16,6 +30,7 @@ class RedisHandler extends BaseHandler implements QueueInterface { private readonly Redis $redis; + private readonly string $luaScript; public function __construct(protected QueueConfig $config) { @@ -25,9 +40,12 @@ public function __construct(protected QueueConfig $config) if (! $this->redis->connect($config->redis['host'], ($config->redis['host'][0] === '/' ? 0 : $config->redis['port']), $config->redis['timeout'])) { throw new CriticalError('Queue: Redis connection failed. Check your configuration.'); } + if (isset($config->redis['username'], $config->redis['password']) && ! $this->redis->auth([$config->redis['username'], $config->redis['password']])) { + throw new CriticalError('Queue: Redis authentication failed. Check your username and password.'); + } if (isset($config->redis['password']) && ! $this->redis->auth($config->redis['password'])) { - throw new CriticalError('Queue: Redis authentication failed.'); + throw new CriticalError('Queue: Redis authentication failed. Check your password.'); } if (isset($config->redis['database']) && ! $this->redis->select($config->redis['database'])) { @@ -37,37 +55,65 @@ public function __construct(protected QueueConfig $config) if (isset($config->redis['prefix']) && ! $this->redis->setOption(Redis::OPT_PREFIX, $config->redis['prefix'])) { throw new CriticalError('Queue: Redis setting prefix failed.'); } + + $locator = new FileLocator(service('autoloader')); + $luaScript = $locator->locateFile('CodeIgniter\Queue\Lua\pop_task', null, 'lua'); + if ($luaScript === false) { + throw new CriticalError('Queue: LUA script for Redis is not available.'); + } + $this->luaScript = file_get_contents($luaScript); } catch (RedisException $e) { throw new CriticalError('Queue: RedisException occurred with message (' . $e->getMessage() . ').'); } } + /** + * Name of the handler. + */ + public function name(): string + { + return 'redis'; + } + /** * Add job to the queue. * * @throws RedisException */ - public function push(string $queue, string $job, array $data): bool + public function push(string $queue, string $job, array $data, ?PayloadMetadata $metadata = null): QueuePushResult { $this->validateJobAndPriority($queue, $job); helper('text'); + $availableAt = Time::now()->addSeconds($this->delay ?? 0); + $jobId = (int) random_string('numeric', 16); + $queueJob = new QueueJob([ - 'id' => random_string('numeric', 16), + 'id' => $jobId, 'queue' => $queue, - 'payload' => new Payload($job, $data), + 'payload' => new Payload($job, $data, $metadata), 'priority' => $this->priority, 'status' => Status::PENDING->value, 'attempts' => 0, - 'available_at' => Time::now()->timestamp, + 'available_at' => $availableAt, ]); - $result = (int) $this->redis->zAdd("queues:{$queue}:{$this->priority}", Time::now()->timestamp, json_encode($queueJob)); + try { + $result = $this->redis->zAdd("queues:{$queue}:{$this->priority}", $availableAt->timestamp, json_encode($queueJob)); + } catch (Throwable $e) { + return QueuePushResult::failure('Unexpected Redis error: ' . $e->getMessage()); + } finally { + $this->priority = $this->delay = null; + } - $this->priority = null; + if ($result === false) { + return QueuePushResult::failure('Failed to add job to Redis.'); + } - return $result > 0; + return (int) $result > 0 + ? QueuePushResult::success($jobId) + : QueuePushResult::failure('Job already exists in the queue.'); } /** @@ -77,29 +123,29 @@ public function push(string $queue, string $job, array $data): bool */ public function pop(string $queue, array $priorities): ?QueueJob { - $tasks = []; - $now = Time::now()->timestamp; - - foreach ($priorities as $priority) { - if ($tasks = $this->redis->zRangeByScore("queues:{$queue}:{$priority}", '-inf', $now, ['limit' => [0, 1]])) { - if ($this->redis->zRem("queues:{$queue}:{$priority}", ...$tasks)) { - break; - } - $tasks = []; - } - } + $now = Time::now()->timestamp; + + // Prepare the arguments for the Lua script + $args = [ + 'queues:' . $queue, // KEYS[1] + $now, // ARGV[2] + json_encode($priorities), // ARGV[3] + ]; - if (empty($tasks[0])) { + // Execute the Lua script + $task = $this->redis->eval($this->luaScript, $args, 1); + + if ($task === false) { return null; } - $queueJob = new QueueJob(json_decode((string) $tasks[0], true)); + $queueJob = new QueueJob(json_decode((string) $task, true)); // Set the actual status as in DB. $queueJob->status = Status::RESERVED->value; $queueJob->syncOriginal(); - $this->redis->hSet("queues:{$queue}::reserved", $queueJob->id, json_encode($queueJob)); + $this->redis->hSet("queues:{$queue}::reserved", (string) $queueJob->id, json_encode($queueJob)); return $queueJob; } @@ -112,10 +158,15 @@ public function pop(string $queue, array $priorities): ?QueueJob public function later(QueueJob $queueJob, int $seconds): bool { $queueJob->status = Status::PENDING->value; - $queueJob->available_at = Time::now()->addSeconds($seconds)->timestamp; - - if ($result = (int) $this->redis->zAdd("queues:{$queueJob->queue}:{$queueJob->priority}", $queueJob->available_at->timestamp, json_encode($queueJob))) { - $this->redis->hDel("queues:{$queueJob->queue}::reserved", $queueJob->id); + $queueJob->available_at = Time::now()->addSeconds($seconds); + + $result = (int) $this->redis->zAdd( + "queues:{$queueJob->queue}:{$queueJob->priority}", + $queueJob->available_at->timestamp, + json_encode($queueJob), + ); + if ($result !== 0) { + $this->redis->hDel("queues:{$queueJob->queue}::reserved", (string) $queueJob->id); } return $result > 0; @@ -130,7 +181,7 @@ public function failed(QueueJob $queueJob, Throwable $err, bool $keepJob): bool $this->logFailed($queueJob, $err); } - return (bool) $this->redis->hDel("queues:{$queueJob->queue}::reserved", $queueJob->id); + return (bool) $this->redis->hDel("queues:{$queueJob->queue}::reserved", (string) $queueJob->id); } /** @@ -145,7 +196,7 @@ public function done(QueueJob $queueJob, bool $keepJob): bool $this->redis->lPush("queues:{$queueJob->queue}::done", json_encode($queueJob)); } - return (bool) $this->redis->hDel("queues:{$queueJob->queue}::reserved", $queueJob->id); + return (bool) $this->redis->hDel("queues:{$queueJob->queue}::reserved", (string) $queueJob->id); } /** diff --git a/src/Interfaces/JobInterface.php b/src/Interfaces/JobInterface.php index 0ae7aeb..85bed8d 100644 --- a/src/Interfaces/JobInterface.php +++ b/src/Interfaces/JobInterface.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Interfaces; interface JobInterface @@ -7,4 +18,8 @@ interface JobInterface public function __construct(array $data); public function process(); + + public function getRetryAfter(): int; + + public function getTries(): int; } diff --git a/src/Interfaces/QueueInterface.php b/src/Interfaces/QueueInterface.php index 5a45c20..30ab6c9 100644 --- a/src/Interfaces/QueueInterface.php +++ b/src/Interfaces/QueueInterface.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Interfaces; use CodeIgniter\Queue\Entities\QueueJob; diff --git a/src/Language/en/Queue.php b/src/Language/en/Queue.php index c4bab3c..82eaa97 100644 --- a/src/Language/en/Queue.php +++ b/src/Language/en/Queue.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + return [ 'generator' => [ 'className' => [ @@ -13,4 +24,6 @@ 'incorrectPriorityFormat' => 'The priority name should consists only lowercase letters.', 'tooLongPriorityName' => 'The priority name is too long. It should be no longer than 64 letters.', 'incorrectQueuePriority' => 'This queue has incorrectly defined priority: "{0}" for the queue: "{1}".', + 'incorrectDelayValue' => 'The number of seconds of delay must be a positive integer.', + 'failedToJsonEncode' => 'Failed to JSON encode queue job: {0}', ]; diff --git a/src/Lua/pop_task.lua b/src/Lua/pop_task.lua new file mode 100644 index 0000000..8ddeb79 --- /dev/null +++ b/src/Lua/pop_task.lua @@ -0,0 +1,17 @@ +local queue = KEYS[1] +local now = tonumber(ARGV[1]) +local priorities = cjson.decode(ARGV[2]) +local task = nil + +for _, priority in ipairs(priorities) do + local key = queue .. ':' .. priority + local tasks = redis.call('ZRANGEBYSCORE', key, '-inf', tostring(now), 'LIMIT', 0, 1) + + if #tasks > 0 then + redis.call('ZREM', key, tasks[1]) + task = tasks[1] + break + end +end + +return task diff --git a/src/Models/QueueJobFailedModel.php b/src/Models/QueueJobFailedModel.php index 2087946..e98b5f3 100644 --- a/src/Models/QueueJobFailedModel.php +++ b/src/Models/QueueJobFailedModel.php @@ -1,9 +1,24 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Models; +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Model; use CodeIgniter\Queue\Entities\QueueJobFailed; +use CodeIgniter\Validation\ValidationInterface; +use Config\Database; class QueueJobFailedModel extends Model { @@ -26,4 +41,19 @@ class QueueJobFailedModel extends Model // Callbacks protected $allowCallbacks = false; + + public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null) + { + $this->DBGroup = config('Queue')->database['dbGroup']; + + /** + * @var BaseConnection|null $db + */ + $db ??= Database::connect($this->DBGroup); + + // Turn off the Strict Mode + $db->transStrict(false); + + parent::__construct($db, $validation); + } } diff --git a/src/Models/QueueJobModel.php b/src/Models/QueueJobModel.php index 329ed6e..4df5bd1 100644 --- a/src/Models/QueueJobModel.php +++ b/src/Models/QueueJobModel.php @@ -1,13 +1,27 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue\Models; use CodeIgniter\Database\BaseBuilder; -use CodeIgniter\Database\RawSql; +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\I18n\Time; use CodeIgniter\Model; use CodeIgniter\Queue\Entities\QueueJob; use CodeIgniter\Queue\Enums\Status; +use CodeIgniter\Validation\ValidationInterface; +use Config\Database; use ReflectionException; class QueueJobModel extends Model @@ -32,6 +46,21 @@ class QueueJobModel extends Model // Callbacks protected $allowCallbacks = false; + public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null) + { + $this->DBGroup = config('Queue')->database['dbGroup']; + + /** + * @var BaseConnection|null $db + */ + $db ??= Database::connect($this->DBGroup); + + // Turn off the Strict Mode + $db->transStrict(false); + + parent::__construct($db, $validation); + } + /** * Get the oldest item from the queue. * @@ -41,7 +70,7 @@ public function getFromQueue(string $name, array $priority): ?QueueJob { // For SQLite3 memory database this will cause problems // so check if we're not in the testing environment first. - if ($this->db->database !== ':memory:') { + if ($this->db->database !== ':memory:' && $this->db->connID !== false) { // Make sure we still have the connection $this->db->reconnect(); } @@ -80,7 +109,7 @@ public function getFromQueue(string $name, array $priority): ?QueueJob */ private function skipLocked(string $sql): string { - if ($this->db->DBDriver === 'SQLite3') { + if ($this->db->DBDriver === 'SQLite3' || config('Queue')->database['skipLocked'] === false) { return $sql; } @@ -90,7 +119,13 @@ private function skipLocked(string $sql): string return str_replace('WHERE', $replace, $sql); } - return $sql .= ' FOR UPDATE SKIP LOCKED'; + if ($this->db->DBDriver === 'OCI8') { + $sql = str_replace('SELECT *', 'SELECT "id"', $sql); + // prepare final query + $sql = sprintf('SELECT * FROM "%s" WHERE "id" = (%s)', $this->db->prefixTable($this->table), $sql); + } + + return $sql . ' FOR UPDATE SKIP LOCKED'; } /** @@ -101,10 +136,28 @@ private function setPriority(BaseBuilder $builder, array $priority): BaseBuilder $builder->whereIn('priority', $priority); if ($priority !== ['default']) { - if ($this->db->DBDriver === 'SQLite3') { - $builder->orderBy(new RawSql('CASE priority ' . implode(' ', array_map(static fn ($value, $key) => "WHEN '{$value}' THEN {$key}", $priority, array_keys($priority))) . ' END')); + if ($this->db->DBDriver !== 'MySQLi') { + $builder->orderBy( + sprintf('CASE %s ', $this->db->protectIdentifiers('priority')) + . implode( + ' ', + array_map(static fn ($value, $key) => "WHEN '{$value}' THEN {$key}", $priority, array_keys($priority)), + ) + . ' END', + '', + false, + ); } else { - $builder->orderBy(new RawSql('FIELD(priority, ' . implode(',', array_map(static fn ($value) => "'{$value}'", $priority)) . ')')); + $builder->orderBy( + 'FIELD(priority, ' + . implode( + ',', + array_map(static fn ($value) => "'{$value}'", $priority), + ) + . ')', + '', + false, + ); } } diff --git a/src/Payload.php b/src/Payload.php deleted file mode 100644 index e641ed9..0000000 --- a/src/Payload.php +++ /dev/null @@ -1,20 +0,0 @@ - $this->job, - 'data' => $this->data, - ]; - } -} diff --git a/src/Payloads/ChainBuilder.php b/src/Payloads/ChainBuilder.php new file mode 100644 index 0000000..772e376 --- /dev/null +++ b/src/Payloads/ChainBuilder.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Payloads; + +use CodeIgniter\Queue\Handlers\BaseHandler; +use CodeIgniter\Queue\QueuePushResult; + +class ChainBuilder +{ + /** + * Collection of jobs in the chain + */ + protected PayloadCollection $payloads; + + public function __construct(protected BaseHandler $handler) + { + $this->payloads = new PayloadCollection(); + } + + /** + * Add a job to the chain + */ + public function push(string $queue, string $jobName, array $data = []): ChainElement + { + $payload = new Payload($jobName, $data); + + $payload->setQueue($queue); + + $this->payloads->add($payload); + + return new ChainElement($payload, $this); + } + + /** + * Dispatch the chain of jobs + */ + public function dispatch(): QueuePushResult + { + if ($this->payloads->count() === 0) { + return QueuePushResult::failure('No jobs to dispatch.'); + } + + $current = $this->payloads->shift(); + $priority = $current->getPriority(); + $delay = $current->getDelay(); + + if ($priority !== null) { + $this->handler->setPriority($priority); + } + + if ($delay !== null) { + $this->handler->setDelay($delay); + } + + // Set chained jobs for the next job + if ($this->payloads->count() > 0) { + $current->setChainedJobs($this->payloads); + } + + // Push to the queue with the specified queue name + return $this->handler->push( + $current->getQueue(), + $current->getJob(), + $current->getData(), + $current->getMetadata(), + ); + } +} diff --git a/src/Payloads/ChainElement.php b/src/Payloads/ChainElement.php new file mode 100644 index 0000000..d8d7bc3 --- /dev/null +++ b/src/Payloads/ChainElement.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Payloads; + +class ChainElement +{ + public function __construct(protected Payload $payload, protected ChainBuilder $chainBuilder) + { + } + + /** + * Set priority for this specific job + */ + public function setPriority(string $priority): self + { + $this->payload->setPriority($priority); + + return $this; + } + + /** + * Set delay for this specific job + */ + public function setDelay(int $delay): self + { + $this->payload->setDelay($delay); + + return $this; + } + + /** + * Push the next job in the chain (method chaining) + */ + public function push(string $queue, string $jobName, array $data = []): ChainElement + { + return $this->chainBuilder->push($queue, $jobName, $data); + } +} diff --git a/src/Payloads/Payload.php b/src/Payloads/Payload.php new file mode 100644 index 0000000..8539a87 --- /dev/null +++ b/src/Payloads/Payload.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Payloads; + +use CodeIgniter\Queue\Exceptions\QueueException; +use CodeIgniter\Queue\Traits\HasQueueValidation; +use JsonSerializable; + +class Payload implements JsonSerializable +{ + use HasQueueValidation; + + /** + * Job metadata + */ + protected PayloadMetadata $metadata; + + public function __construct(protected string $job, protected array $data, ?PayloadMetadata $metadata = null) + { + $this->metadata = $metadata ?? new PayloadMetadata(); + } + + public function getJob(): string + { + return $this->job; + } + + public function getData(): array + { + return $this->data; + } + + public function getMetadata(): PayloadMetadata + { + return $this->metadata; + } + + public function setMetadata(PayloadMetadata $metadata): self + { + $this->metadata = $metadata; + + return $this; + } + + /** + * Set the queue name + * + * @throws QueueException + */ + public function setQueue(string $queue): self + { + $this->validateQueue($queue); + + $this->metadata->set('queue', $queue); + + return $this; + } + + public function getQueue(): ?string + { + return $this->metadata->get('queue'); + } + + /** + * Set the priority + * + * @throws QueueException + */ + public function setPriority(string $priority): self + { + $this->validatePriority($priority); + + $this->metadata->set('priority', $priority); + + return $this; + } + + public function getPriority(): ?string + { + return $this->metadata->get('priority'); + } + + /** + * Set the delay + * + * @throws QueueException + */ + public function setDelay(int $delay): self + { + $this->validateDelay($delay); + + $this->metadata->set('delay', $delay); + + return $this; + } + + public function getDelay(): ?int + { + return $this->metadata->get('delay'); + } + + public function setChainedJobs(PayloadCollection $payloads): self + { + $this->metadata->setChainedJobs($payloads); + + return $this; + } + + public function getChainedJobs(): ?PayloadCollection + { + return $this->metadata->getChainedJobs(); + } + + public function hasChainedJobs(): bool + { + return $this->metadata->hasChainedJobs(); + } + + public function jsonSerialize(): array + { + return [ + 'job' => $this->job, + 'data' => $this->data, + 'metadata' => $this->metadata, + ]; + } + + /** + * Create a Payload from an array + */ + public static function fromArray(array $data): self + { + $job = $data['job'] ?? ''; + $jobData = $data['data'] ?? []; + $metadata = isset($data['metadata']) ? PayloadMetadata::fromArray($data['metadata']) : null; + + return new self($job, $jobData, $metadata); + } +} diff --git a/src/Payloads/PayloadCollection.php b/src/Payloads/PayloadCollection.php new file mode 100644 index 0000000..871c96a --- /dev/null +++ b/src/Payloads/PayloadCollection.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Payloads; + +use ArrayIterator; +use Countable; +use IteratorAggregate; +use JsonSerializable; + +/** + * @template T + * + * @implements IteratorAggregate + */ +class PayloadCollection implements IteratorAggregate, Countable, JsonSerializable +{ + /** + * Create a new payload collection + * + * @param list $items + */ + public function __construct(protected array $items = []) + { + } + + /** + * Add a payload to the collection + */ + public function add(Payload $payload): self + { + $this->items[] = $payload; + + return $this; + } + + /** + * Get the first payload and remove it. + */ + public function shift(): ?Payload + { + if ($this->count() === 0) { + return null; + } + + return array_shift($this->items); + } + + /** + * Convert the collection to an array + */ + public function toArray(): array + { + $result = []; + + foreach ($this->items as $payload) { + $result[] = $payload->jsonSerialize(); + } + + return $result; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } + + public function count(): int + { + return count($this->items); + } + + /** + * @return ArrayIterator + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->items); + } + + /** + * Create a new PayloadCollection from an array + */ + public static function fromArray(array $payloads): self + { + $collection = new self(); + + foreach ($payloads as $payload) { + if (isset($payload['job'], $payload['data'])) { + $collection->add(Payload::fromArray($payload)); + } + } + + return $collection; + } +} diff --git a/src/Payloads/PayloadMetadata.php b/src/Payloads/PayloadMetadata.php new file mode 100644 index 0000000..17c8833 --- /dev/null +++ b/src/Payloads/PayloadMetadata.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Payloads; + +use JsonSerializable; + +class PayloadMetadata implements JsonSerializable +{ + public function __construct(protected array $data = []) + { + } + + /** + * Set chained jobs + */ + public function setChainedJobs(?PayloadCollection $payloads): self + { + if ($payloads !== null) { + $this->data['chainedJobs'] = $payloads; + } else { + unset($this->data['chainedJobs']); + } + + return $this; + } + + /** + * Get chained jobs + */ + public function getChainedJobs(): ?PayloadCollection + { + return $this->data['chainedJobs'] ?? null; + } + + /** + * Check if has chained jobs + */ + public function hasChainedJobs(): bool + { + return isset($this->data['chainedJobs']) && $this->data['chainedJobs']->count() > 0; + } + + /** + * Set a generic metadata value + */ + public function set(string $key, mixed $value): self + { + $this->data[$key] = $value; + + return $this; + } + + /** + * Get a generic metadata value + * + * @param mixed|null $default + */ + public function get(string $key, $default = null) + { + return $this->data[$key] ?? $default; + } + + /** + * Check if a metadata key exists + */ + public function has(string $key): bool + { + return isset($this->data[$key]); + } + + /** + * Remove a metadata key + */ + public function remove(string $key): self + { + unset($this->data[$key]); + + return $this; + } + + /** + * Get all metadata as an array + */ + public function toArray(): array + { + return $this->data; + } + + /** + * JSON serialize implementation + */ + public function jsonSerialize(): array + { + return $this->data; + } + + public static function fromArray(array $data): PayloadMetadata + { + $metadata = new self(); + + foreach ($data as $key => $value) { + // Handle chainedJobs specially + if ($key === 'chainedJobs' && is_array($value)) { + $payloadCollection = new PayloadCollection(); + + foreach ($value as $jobData) { + if (isset($jobData['job'], $jobData['data'])) { + $payload = Payload::fromArray($jobData); + $payloadCollection->add($payload); + } + } + + $metadata->setChainedJobs($payloadCollection); + } else { + // Regular metadata + $metadata->set($key, $value); + } + } + + return $metadata; + } +} diff --git a/src/Queue.php b/src/Queue.php index b491406..6819586 100644 --- a/src/Queue.php +++ b/src/Queue.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Queue; use CodeIgniter\Queue\Config\Queue as QueueConfig; diff --git a/src/QueuePushResult.php b/src/QueuePushResult.php new file mode 100644 index 0000000..efe7456 --- /dev/null +++ b/src/QueuePushResult.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue; + +/** + * Represents the result of a queue push operation. + */ +class QueuePushResult +{ + public function __construct( + protected readonly bool $success, + protected readonly ?int $jobId = null, + protected readonly ?string $error = null, + ) { + } + + /** + * Creates a successful push result. + */ + public static function success(int $jobId): self + { + return new self(true, $jobId); + } + + /** + * Creates a failed push result. + */ + public static function failure(?string $error = null): self + { + return new self(false, null, $error); + } + + /** + * Returns whether the push operation was successful. + */ + public function getStatus(): bool + { + return $this->success; + } + + /** + * Returns the job ID if the push was successful, null otherwise. + */ + public function getJobId(): ?int + { + return $this->jobId; + } + + /** + * Returns the error message if the push failed, null otherwise. + */ + public function getError(): ?string + { + return $this->error; + } +} diff --git a/src/Traits/HasQueueValidation.php b/src/Traits/HasQueueValidation.php new file mode 100644 index 0000000..aa66593 --- /dev/null +++ b/src/Traits/HasQueueValidation.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Queue\Traits; + +use CodeIgniter\Queue\Exceptions\QueueException; + +trait HasQueueValidation +{ + /** + * Validate priority value. + * + * @throws QueueException + */ + protected function validatePriority(string $priority): void + { + if (! preg_match('/^[a-z_-]+$/', $priority)) { + throw QueueException::forIncorrectPriorityFormat(); + } + + if (strlen($priority) > 64) { + throw QueueException::forTooLongPriorityName(); + } + } + + /** + * Validate delay value. + * + * @throws QueueException + */ + protected function validateDelay(int $delay): void + { + if ($delay < 0) { + throw QueueException::forIncorrectDelayValue(); + } + } + + /** + * Validate queue name. + * + * @throws QueueException + */ + protected function validateQueue(string $queue): void + { + if (! preg_match('/^[a-z0-9_-]+$/', $queue)) { + throw QueueException::forIncorrectQueueFormat(); + } + + if (strlen($queue) > 64) { + throw QueueException::forTooLongQueueName(); + } + } +} diff --git a/tests/Commands/QueueClearTest.php b/tests/Commands/QueueClearTest.php new file mode 100644 index 0000000..6024837 --- /dev/null +++ b/tests/Commands/QueueClearTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Commands; + +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Tests\Support\CLITestCase; + +/** + * @internal + */ +final class QueueClearTest extends CLITestCase +{ + public function testRunWithNoQueueName(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addErrorFilter(); + + $this->assertNotFalse(command('queue:clear')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeErrorFilter(); + + $this->assertSame('The queueName is not specified.', $output); + } + + public function testRun(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:clear test')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('Queue test has been cleared.', $output); + } +} diff --git a/tests/Commands/QueueFailedTest.php b/tests/Commands/QueueFailedTest.php new file mode 100644 index 0000000..fed02df --- /dev/null +++ b/tests/Commands/QueueFailedTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Commands; + +use CodeIgniter\I18n\Time; +use CodeIgniter\Queue\Models\QueueJobFailedModel; +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Exception; +use Tests\Support\CLITestCase; + +/** + * @internal + */ +final class QueueFailedTest extends CLITestCase +{ + /** + * @throws Exception + */ + public function testRun(): void + { + Time::setTestNow('2023-12-19 14:15:16'); + + fake(QueueJobFailedModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => ['job' => 'failure', 'data' => ['key' => 'value']], + 'priority' => 'default', + 'exception' => 'Exception: Test error', + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:failed')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $expect = <<<'EOT' + +----+------------+-------+----------------------------+---------------------+ + | ID | Connection | Queue | Class | Failed At | + +----+------------+-------+----------------------------+---------------------+ + | 1 | database | test | Tests\Support\Jobs\Failure | 2023-12-19 14:15:16 | + +----+------------+-------+----------------------------+---------------------+ + EOT; + + $this->assertSame($expect, $output); + } +} diff --git a/tests/Commands/QueueFlushTest.php b/tests/Commands/QueueFlushTest.php new file mode 100644 index 0000000..a6395f2 --- /dev/null +++ b/tests/Commands/QueueFlushTest.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Commands; + +use CodeIgniter\I18n\Time; +use CodeIgniter\Queue\Models\QueueJobFailedModel; +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Exception; +use Tests\Support\CLITestCase; + +/** + * @internal + */ +final class QueueFlushTest extends CLITestCase +{ + /** + * @throws Exception + */ + public function testRun(): void + { + Time::setTestNow('2023-12-19 14:15:16'); + + fake(QueueJobFailedModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => ['job' => 'failure', 'data' => ['key' => 'value']], + 'priority' => 'default', + 'exception' => 'Exception: Test error', + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:flush')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('All failed jobs has been removed from the queue ', $output); + } + + public function testRunWithQueue(): void + { + Time::setTestNow('2023-12-19 14:15:16'); + + fake(QueueJobFailedModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => ['job' => 'failure', 'data' => ['key' => 'value']], + 'priority' => 'default', + 'exception' => 'Exception: Test error', + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:flush -queue default')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('All failed jobs has been removed from the queue default', $output); + } + + public function testRunWithQueueAndHour(): void + { + Time::setTestNow('2023-12-19 14:15:16'); + + fake(QueueJobFailedModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => ['job' => 'failure', 'data' => ['key' => 'value']], + 'priority' => 'default', + 'exception' => 'Exception: Test error', + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:flush -queue default -hours 2')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('All failed jobs older than 2 hours has been removed from the queue default', $output); + } +} diff --git a/tests/Commands/QueueForgetTest.php b/tests/Commands/QueueForgetTest.php new file mode 100644 index 0000000..388cadc --- /dev/null +++ b/tests/Commands/QueueForgetTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Commands; + +use CodeIgniter\Queue\Models\QueueJobFailedModel; +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Tests\Support\CLITestCase; + +/** + * @internal + */ +final class QueueForgetTest extends CLITestCase +{ + public function testRunWithNoQueueName(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addErrorFilter(); + + $this->assertNotFalse(command('queue:forget')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeErrorFilter(); + + $this->assertSame('The ID of the failed job is not specified.', $output); + } + + public function testRunFailed(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:forget 123')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('Could not find the failed job with ID 123', $output); + } + + public function testRun(): void + { + fake(QueueJobFailedModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => ['job' => 'failure', 'data' => ['key' => 'value']], + 'priority' => 'default', + 'exception' => 'Exception: Test error', + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:forget 1')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('Failed job with ID 1 has been removed.', $output); + } +} diff --git a/tests/Commands/QueuePublishTest.php b/tests/Commands/QueuePublishTest.php new file mode 100644 index 0000000..dd456ea --- /dev/null +++ b/tests/Commands/QueuePublishTest.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Commands; + +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Tests\Support\CLITestCase; + +/** + * @internal + */ +final class QueuePublishTest extends CLITestCase +{ + public function testRun(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:publish')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame(' Published! You can customize the configuration by editing the "app/Config/Queue.php" file.', $output); + } +} diff --git a/tests/Commands/QueueRetryTest.php b/tests/Commands/QueueRetryTest.php new file mode 100644 index 0000000..fe0da19 --- /dev/null +++ b/tests/Commands/QueueRetryTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Commands; + +use CodeIgniter\Queue\Models\QueueJobFailedModel; +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Tests\Support\CLITestCase; + +/** + * @internal + */ +final class QueueRetryTest extends CLITestCase +{ + public function testRunWithNoQueueName(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addErrorFilter(); + + $this->assertNotFalse(command('queue:retry')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeErrorFilter(); + + $this->assertSame('The ID of the failed job is not specified.', $output); + } + + public function testRunFailed(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:retry all -queue test')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('No failed jobs has been restored to the queue test', $output); + } + + public function testRun(): void + { + fake(QueueJobFailedModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => ['job' => 'failure', 'data' => ['key' => 'value']], + 'priority' => 'default', + 'exception' => 'Exception: Test error', + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:retry 1')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('1 failed job(s) has been restored to the queue ', $output); + } +} diff --git a/tests/Commands/QueueStopTest.php b/tests/Commands/QueueStopTest.php new file mode 100644 index 0000000..94e2631 --- /dev/null +++ b/tests/Commands/QueueStopTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Commands; + +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Tests\Support\CLITestCase; + +/** + * @internal + */ +final class QueueStopTest extends CLITestCase +{ + public function testRunWithNoQueueName(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addErrorFilter(); + + $this->assertNotFalse(command('queue:stop')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeErrorFilter(); + + $this->assertSame('The queueName is not specified.', $output); + } + + public function testRun(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:stop test')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('Queue will be stopped after the current job finish', $output); + } +} diff --git a/tests/Commands/QueueWorkTest.php b/tests/Commands/QueueWorkTest.php new file mode 100644 index 0000000..af39092 --- /dev/null +++ b/tests/Commands/QueueWorkTest.php @@ -0,0 +1,337 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Commands; + +use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Config\Services; +use CodeIgniter\I18n\Time; +use CodeIgniter\Queue\Models\QueueJobModel; +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Tests\Support\CLITestCase; + +/** + * @internal + */ +final class QueueWorkTest extends CLITestCase +{ + public function testRunWithNoQueueName(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addErrorFilter(); + + $this->assertNotFalse(command('queue:work')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeErrorFilter(); + + $this->assertSame('The queueName is not specified.', $output); + } + + public function testRunWithEmptyQueue(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:work test --stop-when-empty')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $expect = <<<'EOT' + Listening for the jobs with the queue: test + + + No job available. Stopping. + EOT; + + $this->assertSame($expect, $output); + } + + public function testRunWithQueueFailed(): void + { + Time::setTestNow('2023-12-19 14:15:16'); + + fake(QueueJobModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => ['job' => 'failure', 'data' => ['key' => 'value']], + 'priority' => 'default', + 'status' => 0, + 'attempts' => 0, + 'available_at' => 1_702_977_074, + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:work test sleep 1 --stop-when-empty')); + $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('Listening for the jobs with the queue: test', $this->getLine(0)); + $this->assertSame('Starting a new job: failure, with ID: 1', $this->getLine(3)); + $this->assertSame('The processing of this job failed', $this->getLine(4)); + $this->assertSame('No job available. Stopping.', $this->getLine(7)); + } + + public function testRunWithQueueSucceed(): void + { + Time::setTestNow('2023-12-19 14:15:16'); + + fake(QueueJobModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => ['job' => 'success', 'data' => ['key' => 'value']], + 'priority' => 'default', + 'status' => 0, + 'attempts' => 0, + 'available_at' => 1_702_977_074, + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:work test sleep 1 --stop-when-empty')); + $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('Listening for the jobs with the queue: test', $this->getLine(0)); + $this->assertSame('Starting a new job: success, with ID: 1', $this->getLine(3)); + $this->assertSame('The processing of this job was successful', $this->getLine(4)); + $this->assertSame('No job available. Stopping.', $this->getLine(7)); + } + + public function testRunWithChainedQueueSucceed(): void + { + Time::setTestNow('2023-12-19 14:15:16'); + + fake(QueueJobModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => [ + 'job' => 'success', + 'data' => ['key' => 'value'], + 'metadata' => [ + 'queue' => 'test', + 'chainedJobs' => [ + [ + 'job' => 'success', + 'data' => [ + 'key3' => 'value3', + ], + 'metadata' => [ + 'queue' => 'queue', + 'priority' => 'high', + 'delay' => 30, + ], + ], + ], + ], + ], + 'priority' => 'default', + 'status' => 0, + 'attempts' => 0, + 'available_at' => 1_702_977_074, + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:work test sleep 1 --stop-when-empty')); + $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('Listening for the jobs with the queue: test', $this->getLine(0)); + $this->assertSame('Starting a new job: success, with ID: 1', $this->getLine(3)); + $this->assertSame('The processing of this job was successful', $this->getLine(4)); + $this->assertSame('Chained job: success has been placed in the queue: queue', $this->getLine(5)); + $this->assertSame('No job available. Stopping.', $this->getLine(8)); + + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue', + 'payload' => json_encode([ + 'job' => 'success', + 'data' => ['key3' => 'value3'], + 'metadata' => [ + 'queue' => 'queue', + 'priority' => 'high', + 'delay' => 30, + ], + ]), + ]); + } + + public function testRunWithTaskLock(): void + { + $lockKey = 'test_lock_key'; + $lockTTL = 300; // 5 minutes + + Time::setTestNow('2023-12-19 14:15:16'); + + $cache = $this->createMock(CacheInterface::class); + + // Set up expectations + $cache->expects($this->once()) + ->method('save') + ->with($lockKey, $this->anything(), $lockTTL) + ->willReturn(true); + + $cache->expects($this->once()) + ->method('delete') + ->with($lockKey) + ->willReturn(true); + + // Replace the cache service + Services::injectMock('cache', $cache); + + fake(QueueJobModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => [ + 'job' => 'success', + 'data' => ['key' => 'value'], + 'metadata' => [ + 'taskLockKey' => $lockKey, + 'taskLockTTL' => $lockTTL, + 'queue' => 'test', + ], + ], + 'priority' => 'default', + 'status' => 0, + 'attempts' => 0, + 'available_at' => 1_702_977_074, + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:work test sleep 1 --stop-when-empty')); + $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('Listening for the jobs with the queue: test', $this->getLine(0)); + $this->assertSame('Starting a new job: success, with ID: 1', $this->getLine(3)); + $this->assertSame('The processing of this job was successful', $this->getLine(4)); + } + + public function testRunWithPermanentTaskLock(): void + { + $lockKey = 'permanent_lock_key'; + $lockTTL = 0; // Permanent lock + + Time::setTestNow('2023-12-19 14:15:16'); + + $cache = $this->createMock(CacheInterface::class); + + // For permanent lock (TTL=0), save should NOT be called + $cache->expects($this->never()) + ->method('save'); + + $cache->expects($this->once()) + ->method('delete') + ->with($lockKey) + ->willReturn(true); + + // Replace the cache service + Services::injectMock('cache', $cache); + + fake(QueueJobModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => [ + 'job' => 'success', + 'data' => ['key4' => 'value4'], + 'metadata' => [ + 'taskLockKey' => $lockKey, + 'taskLockTTL' => $lockTTL, + 'queue' => 'test', + ], + ], + 'priority' => 'default', + 'status' => 0, + 'attempts' => 0, + 'available_at' => 1_702_977_074, + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:work test sleep 1 --stop-when-empty')); + $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('Listening for the jobs with the queue: test', $this->getLine(0)); + $this->assertSame('Starting a new job: success, with ID: 1', $this->getLine(3)); + $this->assertSame('The processing of this job was successful', $this->getLine(4)); + } + + public function testLockClearedOnFailure(): void + { + $lockKey = 'failure_lock_key'; + $lockTTL = 300; + + Time::setTestNow('2023-12-19 14:15:16'); + + $cache = $this->createMock(CacheInterface::class); + + // Set up expectations + $cache->expects($this->once()) + ->method('save') + ->with($lockKey, $this->anything(), $lockTTL) + ->willReturn(true); + + $cache->expects($this->once()) + ->method('delete') + ->with($lockKey) + ->willReturn(true); + + // Replace the cache service + Services::injectMock('cache', $cache); + + fake(QueueJobModel::class, [ + 'connection' => 'database', + 'queue' => 'test', + 'payload' => [ + 'job' => 'failure', + 'data' => ['key' => 'value'], + 'metadata' => [ + 'taskLockKey' => $lockKey, + 'taskLockTTL' => $lockTTL, + 'queue' => 'test', + ], + ], + 'priority' => 'default', + 'status' => 0, + 'attempts' => 0, + 'available_at' => 1_702_977_074, + ]); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('queue:work test sleep 1 --stop-when-empty')); + $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame('Listening for the jobs with the queue: test', $this->getLine(0)); + $this->assertSame('Starting a new job: failure, with ID: 1', $this->getLine(3)); + $this->assertSame('The processing of this job failed', $this->getLine(4)); + } +} diff --git a/tests/DatabaseHandlerTest.php b/tests/DatabaseHandlerTest.php index 6ab2aa9..f189f78 100644 --- a/tests/DatabaseHandlerTest.php +++ b/tests/DatabaseHandlerTest.php @@ -1,14 +1,26 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace Tests; -use CodeIgniter\Test\ReflectionHelper; -use Exception; +use CodeIgniter\I18n\Time; use CodeIgniter\Queue\Entities\QueueJob; use CodeIgniter\Queue\Enums\Status; use CodeIgniter\Queue\Exceptions\QueueException; use CodeIgniter\Queue\Handlers\DatabaseHandler; use CodeIgniter\Queue\Models\QueueJobFailedModel; +use CodeIgniter\Test\ReflectionHelper; +use Exception; use ReflectionException; use Tests\Support\Config\Queue as QueueConfig; use Tests\Support\Database\Seeds\TestDatabaseQueueSeeder; @@ -31,13 +43,13 @@ protected function setUp(): void $this->config = config(QueueConfig::class); } - public function testDatabaseHandler() + public function testDatabaseHandler(): void { $handler = new DatabaseHandler($this->config); $this->assertInstanceOf(DatabaseHandler::class, $handler); } - public function testPriority() + public function testPriority(): void { $handler = new DatabaseHandler($this->config); $handler->setPriority('high'); @@ -45,7 +57,7 @@ public function testPriority() $this->assertSame('high', self::getPrivateProperty($handler, 'priority')); } - public function testPriorityNameException() + public function testPriorityNameException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('The priority name should consists only lowercase letters.'); @@ -54,7 +66,7 @@ public function testPriorityNameException() $handler->setPriority('high_:'); } - public function testPriorityNameLengthException() + public function testPriorityNameLengthException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('The priority name is too long. It should be no longer than 64 letters.'); @@ -66,70 +78,189 @@ public function testPriorityNameLengthException() /** * @throws ReflectionException */ - public function testPush() + public function testPush(): void { + Time::setTestNow('2023-12-29 14:15:16'); + $handler = new DatabaseHandler($this->config); $result = $handler->push('queue', 'success', ['key' => 'value']); - $this->assertTrue($result); + $this->assertTrue($result->getStatus()); $this->seeInDatabase('queue_jobs', [ - 'queue' => 'queue', - 'payload' => json_encode(['job' => 'success', 'data' => ['key' => 'value']]), + 'queue' => 'queue', + 'payload' => json_encode(['job' => 'success', 'data' => ['key' => 'value'], 'metadata' => []]), + 'available_at' => 1703859316, ]); } /** * @throws ReflectionException */ - public function testPushWithPriority() + public function testPushWithPriority(): void { + Time::setTestNow('2023-12-29 14:15:16'); + $handler = new DatabaseHandler($this->config); $result = $handler->setPriority('high')->push('queue', 'success', ['key' => 'value']); - $this->assertTrue($result); + $this->assertTrue($result->getStatus()); $this->seeInDatabase('queue_jobs', [ - 'queue' => 'queue', - 'payload' => json_encode(['job' => 'success', 'data' => ['key' => 'value']]), - 'priority' => 'high', + 'queue' => 'queue', + 'payload' => json_encode(['job' => 'success', 'data' => ['key' => 'value'], 'metadata' => []]), + 'priority' => 'high', + 'available_at' => 1703859316, ]); } - public function testPushAndPopWithPriority() + /** + * @throws ReflectionException + */ + public function testPushAndPopWithPriority(): void { + Time::setTestNow('2023-12-29 14:15:16'); + $handler = new DatabaseHandler($this->config); $result = $handler->push('queue', 'success', ['key1' => 'value1']); - $this->assertTrue($result); + $this->assertTrue($result->getStatus()); $this->seeInDatabase('queue_jobs', [ - 'queue' => 'queue', - 'payload' => json_encode(['job' => 'success', 'data' => ['key1' => 'value1']]), - 'priority' => 'low', + 'queue' => 'queue', + 'payload' => json_encode(['job' => 'success', 'data' => ['key1' => 'value1'], 'metadata' => []]), + 'priority' => 'low', + 'available_at' => 1703859316, ]); $result = $handler->setPriority('high')->push('queue', 'success', ['key2' => 'value2']); - $this->assertTrue($result); + $this->assertTrue($result->getStatus()); $this->seeInDatabase('queue_jobs', [ - 'queue' => 'queue', - 'payload' => json_encode(['job' => 'success', 'data' => ['key2' => 'value2']]), - 'priority' => 'high', + 'queue' => 'queue', + 'payload' => json_encode(['job' => 'success', 'data' => ['key2' => 'value2'], 'metadata' => []]), + 'priority' => 'high', + 'available_at' => 1703859316, ]); $result = $handler->pop('queue', ['high', 'low']); $this->assertInstanceOf(QueueJob::class, $result); - $payload = ['job' => 'success', 'data' => ['key2' => 'value2']]; + $payload = ['job' => 'success', 'data' => ['key2' => 'value2'], 'metadata' => []]; $this->assertSame($payload, $result->payload); $result = $handler->pop('queue', ['high', 'low']); $this->assertInstanceOf(QueueJob::class, $result); - $payload = ['job' => 'success', 'data' => ['key1' => 'value1']]; + $payload = ['job' => 'success', 'data' => ['key1' => 'value1'], 'metadata' => []]; $this->assertSame($payload, $result->payload); } + /** + * @throws Exception + */ + public function testPushWithDelay(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new DatabaseHandler($this->config); + $result = $handler->setDelay(MINUTE)->push('queue-delay', 'success', ['key' => 'value']); + + $this->assertTrue($result->getStatus()); + + $availableAt = 1703859376; + + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue-delay', + 'payload' => json_encode(['job' => 'success', 'data' => ['key' => 'value'], 'metadata' => []]), + 'available_at' => $availableAt, + ]); + + $this->assertEqualsWithDelta(MINUTE, $availableAt - Time::now()->getTimestamp(), 1); + } + + /** + * @throws Exception + */ + public function testChain(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->push('queue', 'success', ['key2' => 'value2']); + }); + + $this->assertTrue($result->getStatus()); + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue', + 'payload' => json_encode([ + 'job' => 'success', + 'data' => ['key1' => 'value1'], + 'metadata' => [ + 'queue' => 'queue', + 'chainedJobs' => [ + [ + 'job' => 'success', 'data' => ['key2' => 'value2'], 'metadata' => ['queue' => 'queue']], + ], + ], + ]), + 'available_at' => 1703859316, + ]); + } + + public function testChainWithPriorityAndDelay(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->setPriority('high') + ->setDelay(60) + ->push('queue', 'success', ['key2' => 'value2']) + ->setPriority('low') + ->setDelay(120); + }); + + $this->assertTrue($result->getStatus()); + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue', + 'payload' => json_encode([ + 'job' => 'success', + 'data' => ['key1' => 'value1'], + 'metadata' => [ + 'queue' => 'queue', + 'priority' => 'high', + 'delay' => 60, + 'chainedJobs' => [ + [ + 'job' => 'success', + 'data' => ['key2' => 'value2'], + 'metadata' => [ + 'queue' => 'queue', + 'priority' => 'low', + 'delay' => 120, + ], + ], + ], + ], + ]), + 'available_at' => 1703859316 + 60, // Adding delay to available_at + ]); + } + + public function testPushWithDelayException(): void + { + $this->expectException(QueueException::class); + $this->expectExceptionMessage('The number of seconds of delay must be a positive integer.'); + + $handler = new DatabaseHandler($this->config); + $handler->setDelay(-60); + } + /** * @throws ReflectionException */ - public function testPushException() + public function testPushException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('This job name is not defined in the $jobHandlers array.'); @@ -141,7 +272,7 @@ public function testPushException() /** * @throws ReflectionException */ - public function testPushWithPriorityException() + public function testPushWithPriorityException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('This queue has incorrectly defined priority: "invalid" for the queue: "queue".'); @@ -153,7 +284,7 @@ public function testPushWithPriorityException() /** * @throws ReflectionException */ - public function testPushWithIncorrectQueueFormatException() + public function testPushWithIncorrectQueueFormatException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('The queue name should consists only lowercase letters or numbers.'); @@ -165,7 +296,7 @@ public function testPushWithIncorrectQueueFormatException() /** * @throws ReflectionException */ - public function testPushWithTooLongQueueNameException() + public function testPushWithTooLongQueueNameException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('The queue name is too long. It should be no longer than 64 letters.'); @@ -177,7 +308,7 @@ public function testPushWithTooLongQueueNameException() /** * @throws ReflectionException */ - public function testPop() + public function testPop(): void { $handler = new DatabaseHandler($this->config); $result = $handler->pop('queue1', ['default']); @@ -192,7 +323,7 @@ public function testPop() /** * @throws ReflectionException */ - public function testPopEmpty() + public function testPopEmpty(): void { $handler = new DatabaseHandler($this->config); $result = $handler->pop('queue123', ['default']); @@ -203,8 +334,10 @@ public function testPopEmpty() /** * @throws ReflectionException */ - public function testLater() + public function testLater(): void { + Time::setTestNow('2023-12-29 14:15:16'); + $handler = new DatabaseHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -217,16 +350,19 @@ public function testLater() $this->assertTrue($result); $this->seeInDatabase('queue_jobs', [ - 'id' => 2, - 'status' => Status::PENDING->value, + 'id' => 2, + 'status' => Status::PENDING->value, + 'available_at' => Time::now()->addSeconds(60)->timestamp, ]); } /** * @throws ReflectionException */ - public function testFailedAndKeepJob() + public function testFailedAndKeepJob(): void { + Time::setTestNow('2023-12-29 14:15:16'); + $handler = new DatabaseHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -241,10 +377,11 @@ public function testFailedAndKeepJob() 'id' => 2, 'connection' => 'database', 'queue' => 'queue1', + 'failed_at' => 1703859316, ]); } - public function testFailedAndDontKeepJob() + public function testFailedAndDontKeepJob(): void { $handler = new DatabaseHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -266,7 +403,7 @@ public function testFailedAndDontKeepJob() /** * @throws ReflectionException */ - public function testDoneAndKeepJob() + public function testDoneAndKeepJob(): void { $handler = new DatabaseHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -283,7 +420,7 @@ public function testDoneAndKeepJob() /** * @throws ReflectionException */ - public function testDoneAndDontKeepJob() + public function testDoneAndDontKeepJob(): void { $handler = new DatabaseHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -296,7 +433,7 @@ public function testDoneAndDontKeepJob() ]); } - public function testClear() + public function testClear(): void { $handler = new DatabaseHandler($this->config); $result = $handler->clear('queue1'); @@ -311,24 +448,24 @@ public function testClear() ]); } - public function testRetry() + public function testRetry(): void { $handler = new DatabaseHandler($this->config); $count = $handler->retry(1, 'queue1'); - $this->assertSame($count, 1); + $this->assertSame(1, $count); $this->seeInDatabase('queue_jobs', [ 'id' => 3, 'queue' => 'queue1', - 'payload' => json_encode(['job' => 'failure', 'data' => []]), + 'payload' => json_encode(['job' => 'failure', 'data' => [], 'metadata' => []]), ]); $this->dontSeeInDatabase('queue_jobs_failed', [ 'id' => 1, ]); } - public function testForget() + public function testForget(): void { $handler = new DatabaseHandler($this->config); $result = $handler->forget(1); @@ -340,7 +477,7 @@ public function testForget() ]); } - public function testForgetFalse() + public function testForgetFalse(): void { $handler = new DatabaseHandler($this->config); $result = $handler->forget(1111); @@ -351,7 +488,7 @@ public function testForgetFalse() /** * @throws ReflectionException */ - public function testFlush() + public function testFlush(): void { $handler = new DatabaseHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -373,7 +510,7 @@ public function testFlush() ]); } - public function testFlushAll() + public function testFlushAll(): void { $handler = new DatabaseHandler($this->config); $handler->flush(null, null); @@ -382,7 +519,7 @@ public function testFlushAll() ]); } - public function testListFailed() + public function testListFailed(): void { $handler = new DatabaseHandler($this->config); $list = $handler->listFailed('queue1'); diff --git a/tests/Models/QueueJobModelTest.php b/tests/Models/QueueJobModelTest.php new file mode 100644 index 0000000..cf829d3 --- /dev/null +++ b/tests/Models/QueueJobModelTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Models; + +use CodeIgniter\Queue\Models\QueueJobModel; +use CodeIgniter\Test\ReflectionHelper; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class QueueJobModelTest extends TestCase +{ + use ReflectionHelper; + + public function testQueueJobModel(): void + { + $model = model(QueueJobModel::class); + $this->assertInstanceOf(QueueJobModel::class, $model); + } + + public function testSkipLocked(): void + { + $model = model(QueueJobModel::class); + $method = $this->getPrivateMethodInvoker($model, 'skipLocked'); + + $sql = 'SELECT * FROM queue_jobs WHERE queue = "test" AND status = 0 AND available_at < 123456 LIMIT 1'; + $result = $method($sql); + + if ($model->db->DBDriver === 'SQLite3') { + $this->assertSame($sql, $result); + } elseif ($model->db->DBDriver === 'SQLSRV') { + $this->assertStringContainsString('WITH (ROWLOCK,UPDLOCK,READPAST) WHERE', $result); + } else { + $this->assertStringContainsString('FOR UPDATE SKIP LOCKED', $result); + } + } + + public function testSkipLockedFalse(): void + { + config('Queue')->database['skipLocked'] = false; + + $model = model(QueueJobModel::class); + $method = $this->getPrivateMethodInvoker($model, 'skipLocked'); + + $sql = 'SELECT * FROM queue_jobs WHERE queue = "test" AND status = 0 AND available_at < 123456 LIMIT 1'; + $result = $method($sql); + + $this->assertSame($sql, $result); + } +} diff --git a/tests/Payloads/ChainBuilderTest.php b/tests/Payloads/ChainBuilderTest.php new file mode 100644 index 0000000..6aa91ba --- /dev/null +++ b/tests/Payloads/ChainBuilderTest.php @@ -0,0 +1,160 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Payloads; + +use CodeIgniter\I18n\Time; +use CodeIgniter\Queue\Handlers\DatabaseHandler; +use CodeIgniter\Queue\Payloads\ChainBuilder; +use CodeIgniter\Queue\Payloads\ChainElement; +use Tests\Support\Config\Queue as QueueConfig; +use Tests\Support\Database\Seeds\TestDatabaseQueueSeeder; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class ChainBuilderTest extends TestCase +{ + protected $seed = TestDatabaseQueueSeeder::class; + private QueueConfig $config; + + protected function setUp(): void + { + parent::setUp(); + + $this->config = config(QueueConfig::class); + } + + public function testChainBuilder(): void + { + $handler = new DatabaseHandler($this->config); + $chainBuilder = new ChainBuilder($handler); + + $this->assertInstanceOf(ChainBuilder::class, $chainBuilder); + } + + public function testPush(): void + { + $handler = new DatabaseHandler($this->config); + $chainBuilder = new ChainBuilder($handler); + + $chainElement = $chainBuilder->push('queue', 'job', ['data' => 'value']); + + $this->assertInstanceOf(ChainElement::class, $chainElement); + } + + public function testChainWithSingleJob(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain->push('queue', 'success', ['key' => 'value']); + }); + + $this->assertTrue($result->getStatus()); + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue', + 'payload' => json_encode([ + 'job' => 'success', + 'data' => ['key' => 'value'], + 'metadata' => [ + 'queue' => 'queue', + ], + ]), + 'available_at' => 1703859316, + ]); + } + + public function testEmptyChain(): void + { + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + // No jobs added + }); + + $this->assertFalse($result->getStatus()); + $this->seeInDatabase('queue_jobs', []); + } + + public function testMultipleDifferentQueues(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue1', 'success', ['key1' => 'value1']) + ->push('queue2', 'success', ['key2' => 'value2']); + }); + + $this->assertTrue($result->getStatus()); + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue1', + 'payload' => json_encode([ + 'job' => 'success', + 'data' => ['key1' => 'value1'], + 'metadata' => [ + 'queue' => 'queue1', + 'chainedJobs' => [ + [ + 'job' => 'success', + 'data' => ['key2' => 'value2'], + 'metadata' => ['queue' => 'queue2'], + ], + ], + ], + ]), + 'available_at' => 1703859316, + ]); + } + + public function testChainWithManyJobs(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new DatabaseHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->push('queue', 'success', ['key2' => 'value2']) + ->push('queue', 'success', ['key3' => 'value3']); + }); + + $this->assertTrue($result->getStatus()); + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue', + 'payload' => json_encode([ + 'job' => 'success', + 'data' => ['key1' => 'value1'], + 'metadata' => [ + 'queue' => 'queue', + 'chainedJobs' => [ + [ + 'job' => 'success', + 'data' => ['key2' => 'value2'], + 'metadata' => ['queue' => 'queue'], + ], + [ + 'job' => 'success', + 'data' => ['key3' => 'value3'], + 'metadata' => ['queue' => 'queue'], + ], + ], + ], + ]), + 'available_at' => 1703859316, + ]); + } +} diff --git a/tests/Payloads/ChainElementTest.php b/tests/Payloads/ChainElementTest.php new file mode 100644 index 0000000..2bb232a --- /dev/null +++ b/tests/Payloads/ChainElementTest.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\ThirdParty\queue\tests\Payloads; + +use CodeIgniter\Queue\Payloads\ChainBuilder; +use CodeIgniter\Queue\Payloads\ChainElement; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class ChainElementTest extends TestCase +{ + private Payload $payload; + private ChainBuilder $chainBuilder; + private ChainElement $chainElement; + + protected function setUp(): void + { + parent::setUp(); + + // Create a payload object + $this->payload = new Payload('job', ['key' => 'value']); + $this->payload->setQueue('queue'); + + // Create a mock ChainBuilder + $this->chainBuilder = $this->createMock(ChainBuilder::class); + + // Create the ChainElement to test + $this->chainElement = new ChainElement($this->payload, $this->chainBuilder); + } + + public function testSetPriority(): void + { + $result = $this->chainElement->setPriority('high'); + + $this->assertInstanceOf(ChainElement::class, $result); + $this->assertSame('high', $this->payload->getPriority()); + } + + public function testSetDelay(): void + { + $result = $this->chainElement->setDelay(60); + + $this->assertInstanceOf(ChainElement::class, $result); + $this->assertSame(60, $this->payload->getDelay()); + } + + public function testPush(): void + { + $nextPayload = new Payload('nextJob', ['nextKey' => 'nextValue']); + $nextElement = new ChainElement($nextPayload, $this->chainBuilder); + + /** @phpstan-ignore-next-line */ + $this->chainBuilder->expects($this->once()) + ->method('push') + ->with('queue2', 'job2', ['data' => 'value2']) + ->willReturn($nextElement); + + $result = $this->chainElement->push('queue2', 'job2', ['data' => 'value2']); + + $this->assertInstanceOf(ChainElement::class, $result); + $this->assertSame($nextElement, $result); + } + + public function testMultipleMethodChaining(): void + { + $chainBuilder = $this->createMock(ChainBuilder::class); + $chainBuilder->method('push')->willReturnSelf(); + + $payload = new Payload('job', ['key' => 'value']); + + $chainElement = new ChainElement($payload, $chainBuilder); + + $chainElement + ->setPriority('medium') + ->setDelay(30); + + $this->assertSame('medium', $payload->getPriority()); + $this->assertSame(30, $payload->getDelay()); + } + + public function testCorrectMetadataModification(): void + { + $metadata = new PayloadMetadata(); + $payload = new Payload('job', ['key' => 'value'], $metadata); + + $chainElement = new ChainElement($payload, $this->chainBuilder); + + $chainElement->setPriority('low'); + $chainElement->setDelay(120); + + $this->assertSame('low', $payload->getMetadata()->get('priority')); + $this->assertSame(120, $payload->getMetadata()->get('delay')); + } +} diff --git a/tests/Payloads/PayloadCollectionTest.php b/tests/Payloads/PayloadCollectionTest.php new file mode 100644 index 0000000..13ef3d6 --- /dev/null +++ b/tests/Payloads/PayloadCollectionTest.php @@ -0,0 +1,185 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\ThirdParty\queue\tests\Payloads; + +use ArrayIterator; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadCollection; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class PayloadCollectionTest extends TestCase +{ + private PayloadCollection $collection; + private Payload $payload1; + private Payload $payload2; + + protected function setUp(): void + { + parent::setUp(); + + // Create sample payloads + $this->payload1 = new Payload('job1', ['key1' => 'value1']); + $this->payload1->setQueue('queue1'); + + $this->payload2 = new Payload('job2', ['key2' => 'value2']); + $this->payload2->setQueue('queue2'); + + // Create an empty collection + $this->collection = new PayloadCollection(); + } + + public function testEmptyCollectionCount(): void + { + $this->assertCount(0, $this->collection); + } + + public function testAddPayload(): void + { + $result = $this->collection->add($this->payload1); + + $this->assertInstanceOf(PayloadCollection::class, $result); + $this->assertCount(1, $this->collection); + } + + public function testAddMultiplePayloads(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $this->assertCount(2, $this->collection); + } + + public function testShiftPayload(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $first = $this->collection->shift(); + + $this->assertSame($this->payload1, $first); + $this->assertCount(1, $this->collection); + } + + public function testShiftFromEmptyCollection(): void + { + $result = $this->collection->shift(); + + $this->assertNull($result); + } + + public function testGetIterator(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $iterator = $this->collection->getIterator(); + + $this->assertInstanceOf(ArrayIterator::class, $iterator); + $this->assertCount(2, $iterator); + } + + public function testToArray(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $array = $this->collection->toArray(); + + $this->assertCount(2, $array); + + // Check array structure + $this->assertArrayHasKey('job', $array[0]); + $this->assertArrayHasKey('data', $array[0]); + $this->assertArrayHasKey('metadata', $array[0]); + + $this->assertSame('job1', $array[0]['job']); + $this->assertSame(['key1' => 'value1'], $array[0]['data']); + } + + public function testJsonSerialize(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $json = json_encode($this->collection); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertCount(2, $decoded); + $this->assertSame('job1', $decoded[0]['job']); + $this->assertSame('job2', $decoded[1]['job']); + } + + public function testFromArray(): void + { + $arrayData = [ + [ + 'job' => 'job1', + 'data' => ['key1' => 'value1'], + 'metadata' => ['queue' => 'queue1'], + ], + [ + 'job' => 'job2', + 'data' => ['key2' => 'value2'], + 'metadata' => ['queue' => 'queue2'], + ], + ]; + + $collection = PayloadCollection::fromArray($arrayData); + + $this->assertInstanceOf(PayloadCollection::class, $collection); + $this->assertCount(2, $collection); + + $first = $collection->shift(); + $this->assertInstanceOf(Payload::class, $first); + $this->assertSame('job1', $first->getJob()); + $this->assertSame(['key1' => 'value1'], $first->getData()); + } + + public function testInvalidDataInFromArray(): void + { + $arrayData = [ + ['invalid' => 'data'], // Missing job and data + [ + 'job' => 'job2', + 'data' => ['key2' => 'value2'], + ], + ]; + + $collection = PayloadCollection::fromArray($arrayData); + + // Should only have created one valid payload + $this->assertCount(1, $collection); + } + + public function testIteration(): void + { + $this->collection->add($this->payload1); + $this->collection->add($this->payload2); + + $count = 0; + $jobs = []; + + foreach ($this->collection as $payload) { + $count++; + $jobs[] = $payload->getJob(); + } + + $this->assertSame(2, $count); + $this->assertSame(['job1', 'job2'], $jobs); + } +} diff --git a/tests/Payloads/PayloadMetadataTest.php b/tests/Payloads/PayloadMetadataTest.php new file mode 100644 index 0000000..e0af535 --- /dev/null +++ b/tests/Payloads/PayloadMetadataTest.php @@ -0,0 +1,233 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\ThirdParty\queue\tests\Payloads; + +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadCollection; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class PayloadMetadataTest extends TestCase +{ + private PayloadMetadata $metadata; + + protected function setUp(): void + { + parent::setUp(); + $this->metadata = new PayloadMetadata(); + } + + public function testEmptyMetadata(): void + { + $this->assertSame([], $this->metadata->toArray()); + } + + public function testSetAndGetGenericValue(): void + { + $this->metadata->set('key', 'value'); + + $this->assertSame('value', $this->metadata->get('key')); + } + + public function testGetWithDefault(): void + { + $this->assertSame('default', $this->metadata->get('nonexistent', 'default')); + } + + public function testHasKey(): void + { + $this->metadata->set('key', 'value'); + + $this->assertTrue($this->metadata->has('key')); + $this->assertFalse($this->metadata->has('nonexistent')); + } + + public function testRemoveKey(): void + { + $this->metadata->set('key', 'value'); + $this->metadata->remove('key'); + + $this->assertFalse($this->metadata->has('key')); + } + + public function testSetAndGetChainedJobs(): void + { + $payload1 = new Payload('job1', ['key1' => 'value1']); + $payload2 = new Payload('job2', ['key2' => 'value2']); + + $payloads = new PayloadCollection(); + $payloads->add($payload1); + $payloads->add($payload2); + + $this->metadata->setChainedJobs($payloads); + + $result = $this->metadata->getChainedJobs(); + + $this->assertInstanceOf(PayloadCollection::class, $result); + $this->assertCount(2, $result); + } + + public function testSetChainedJobsToNull(): void + { + $payload = new Payload('job', ['key' => 'value']); + $payloads = new PayloadCollection(); + $payloads->add($payload); + + $this->metadata->setChainedJobs($payloads); + + // Then set to null + $this->metadata->setChainedJobs(null); + + $this->assertNull($this->metadata->getChainedJobs()); + $this->assertFalse($this->metadata->hasChainedJobs()); + } + + public function testHasChainedJobs(): void + { + $this->assertFalse($this->metadata->hasChainedJobs()); + + $payload = new Payload('job', ['key' => 'value']); + $payloads = new PayloadCollection(); + $payloads->add($payload); + + $this->metadata->setChainedJobs($payloads); + + $this->assertTrue($this->metadata->hasChainedJobs()); + } + + public function testHasChainedJobsWithEmptyCollection(): void + { + $emptyCollection = new PayloadCollection(); + $this->metadata->setChainedJobs($emptyCollection); + + $this->assertFalse($this->metadata->hasChainedJobs()); + } + + public function testJsonSerialize(): void + { + $this->metadata->set('queue', 'default'); + $this->metadata->set('priority', 'high'); + + $json = json_encode($this->metadata); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertArrayHasKey('queue', $decoded); + $this->assertArrayHasKey('priority', $decoded); + $this->assertSame('default', $decoded['queue']); + $this->assertSame('high', $decoded['priority']); + } + + public function testJsonSerializeWithChainedJobs(): void + { + $payload = new Payload('job', ['key' => 'value']); + $payload->setQueue('queue'); + + $payloads = new PayloadCollection(); + $payloads->add($payload); + + $this->metadata->setChainedJobs($payloads); + + $json = json_encode($this->metadata); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertArrayHasKey('chainedJobs', $decoded); + $this->assertIsArray($decoded['chainedJobs']); + $this->assertCount(1, $decoded['chainedJobs']); + $this->assertSame('job', $decoded['chainedJobs'][0]['job']); + } + + public function testFromArray(): void + { + $data = [ + 'queue' => 'default', + 'priority' => 'high', + 'delay' => 60, + ]; + + $metadata = PayloadMetadata::fromArray($data); + + $this->assertSame('default', $metadata->get('queue')); + $this->assertSame('high', $metadata->get('priority')); + $this->assertSame(60, $metadata->get('delay')); + } + + public function testFromArrayWithChainedJobs(): void + { + $data = [ + 'queue' => 'default', + 'chainedJobs' => [ + [ + 'job' => 'job1', + 'data' => ['key1' => 'value1'], + 'metadata' => ['queue' => 'queue1'], + ], + [ + 'job' => 'job2', + 'data' => ['key2' => 'value2'], + 'metadata' => ['queue' => 'queue2'], + ], + ], + ]; + + $metadata = PayloadMetadata::fromArray($data); + + $this->assertSame('default', $metadata->get('queue')); + $this->assertTrue($metadata->hasChainedJobs()); + + $chainedJobs = $metadata->getChainedJobs(); + $this->assertInstanceOf(PayloadCollection::class, $chainedJobs); + $this->assertCount(2, $chainedJobs); + + $job1 = $chainedJobs->shift(); + $this->assertSame('job1', $job1->getJob()); + $this->assertSame(['key1' => 'value1'], $job1->getData()); + $this->assertSame('queue1', $job1->getQueue()); + } + + public function testFromArrayWithInvalidChainedJobs(): void + { + $data = [ + 'chainedJobs' => [ + ['invalid' => 'data'], // Missing job and data + [ + 'job' => 'job2', + 'data' => ['key2' => 'value2'], + ], + ], + ]; + + $metadata = PayloadMetadata::fromArray($data); + + $this->assertTrue($metadata->hasChainedJobs()); + $this->assertSame(1, $metadata->getChainedJobs()->count()); + } + + public function testToArray(): void + { + $this->metadata->set('queue', 'default'); + $this->metadata->set('priority', 'high'); + + $array = $this->metadata->toArray(); + + $this->assertArrayHasKey('queue', $array); + $this->assertArrayHasKey('priority', $array); + $this->assertSame('default', $array['queue']); + $this->assertSame('high', $array['priority']); + } +} diff --git a/tests/Payloads/PayloadTest.php b/tests/Payloads/PayloadTest.php new file mode 100644 index 0000000..570cff2 --- /dev/null +++ b/tests/Payloads/PayloadTest.php @@ -0,0 +1,295 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\ThirdParty\queue\tests\Payloads; + +use CodeIgniter\Queue\Exceptions\QueueException; +use CodeIgniter\Queue\Payloads\Payload; +use CodeIgniter\Queue\Payloads\PayloadCollection; +use CodeIgniter\Queue\Payloads\PayloadMetadata; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class PayloadTest extends TestCase +{ + private Payload $payload; + + protected function setUp(): void + { + parent::setUp(); + $this->payload = new Payload('job', ['key' => 'value']); + } + + public function testConstructor(): void + { + $this->assertSame('job', $this->payload->getJob()); + $this->assertSame(['key' => 'value'], $this->payload->getData()); + } + + public function testConstructorWithMetadata(): void + { + $metadata = new PayloadMetadata(); + $metadata->set('priority', 'high'); + + $payload = new Payload('job', ['key' => 'value'], $metadata); + + $this->assertSame('high', $payload->getMetadata()->get('priority')); + } + + public function testGetJob(): void + { + $this->assertSame('job', $this->payload->getJob()); + } + + public function testGetData(): void + { + $this->assertSame(['key' => 'value'], $this->payload->getData()); + } + + public function testGetMetadata(): void + { + $metadata = $this->payload->getMetadata(); + + $this->assertInstanceOf(PayloadMetadata::class, $metadata); + } + + public function testSetMetadata(): void + { + $metadata = new PayloadMetadata(); + $metadata->set('priority', 'high'); + + $result = $this->payload->setMetadata($metadata); + + $this->assertInstanceOf(Payload::class, $result); + $this->assertSame('high', $this->payload->getMetadata()->get('priority')); + } + + public function testSetQueue(): void + { + $result = $this->payload->setQueue('queue'); + + $this->assertInstanceOf(Payload::class, $result); + $this->assertSame('queue', $this->payload->getQueue()); + } + + public function testSetQueueWithInvalidFormat(): void + { + $this->expectException(QueueException::class); + + $this->payload->setQueue('invalid queue name!'); + } + + public function testSetQueueWithTooLongName(): void + { + $this->expectException(QueueException::class); + + $this->payload->setQueue(str_repeat('a', 65)); // 65 characters, too long + } + + public function testGetQueue(): void + { + $this->payload->setQueue('queue'); + + $this->assertSame('queue', $this->payload->getQueue()); + } + + public function testSetPriority(): void + { + $result = $this->payload->setPriority('high'); + + $this->assertInstanceOf(Payload::class, $result); + $this->assertSame('high', $this->payload->getPriority()); + } + + public function testSetPriorityWithInvalidFormat(): void + { + $this->expectException(QueueException::class); + + $this->payload->setPriority('invalid priority!'); + } + + public function testSetPriorityWithTooLongName(): void + { + $this->expectException(QueueException::class); + + $this->payload->setPriority(str_repeat('a', 65)); // 65 characters, too long + } + + public function testGetPriority(): void + { + $this->payload->setPriority('high'); + + $this->assertSame('high', $this->payload->getPriority()); + } + + public function testSetDelay(): void + { + $result = $this->payload->setDelay(60); + + $this->assertInstanceOf(Payload::class, $result); + $this->assertSame(60, $this->payload->getDelay()); + } + + public function testSetDelayWithNegativeValue(): void + { + $this->expectException(QueueException::class); + + $this->payload->setDelay(-1); + } + + public function testGetDelay(): void + { + $this->payload->setDelay(60); + + $this->assertSame(60, $this->payload->getDelay()); + } + + public function testSetChainedJobs(): void + { + $payloads = new PayloadCollection(); + $payloads->add(new Payload('nextJob', ['nextKey' => 'nextValue'])); + + $result = $this->payload->setChainedJobs($payloads); + + $this->assertInstanceOf(Payload::class, $result); + $this->assertTrue($this->payload->hasChainedJobs()); + } + + public function testGetChainedJobs(): void + { + $payloads = new PayloadCollection(); + $payloads->add(new Payload('nextJob', ['nextKey' => 'nextValue'])); + + $this->payload->setChainedJobs($payloads); + $chainedJobs = $this->payload->getChainedJobs(); + + $this->assertInstanceOf(PayloadCollection::class, $chainedJobs); + $this->assertCount(1, $chainedJobs); + } + + public function testHasChainedJobs(): void + { + $this->assertFalse($this->payload->hasChainedJobs()); + + $payloads = new PayloadCollection(); + $payloads->add(new Payload('nextJob', ['nextKey' => 'nextValue'])); + + $this->payload->setChainedJobs($payloads); + + $this->assertTrue($this->payload->hasChainedJobs()); + } + + public function testJsonSerialize(): void + { + $this->payload->setQueue('queue'); + $this->payload->setPriority('high'); + + $json = json_encode($this->payload); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertSame('job', $decoded['job']); + $this->assertSame(['key' => 'value'], $decoded['data']); + $this->assertIsArray($decoded['metadata']); + $this->assertSame('queue', $decoded['metadata']['queue']); + $this->assertSame('high', $decoded['metadata']['priority']); + } + + public function testJsonSerializeWithChainedJobs(): void + { + $this->payload->setQueue('queue'); + + $nextPayload = new Payload('nextJob', ['nextKey' => 'nextValue']); + $nextPayload->setQueue('queue'); + + $payloads = new PayloadCollection(); + $payloads->add($nextPayload); + + $this->payload->setChainedJobs($payloads); + + $json = json_encode($this->payload); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertArrayHasKey('metadata', $decoded); + $this->assertArrayHasKey('chainedJobs', $decoded['metadata']); + $this->assertIsArray($decoded['metadata']['chainedJobs']); + $this->assertCount(1, $decoded['metadata']['chainedJobs']); + $this->assertSame('nextJob', $decoded['metadata']['chainedJobs'][0]['job']); + $this->assertSame(['nextKey' => 'nextValue'], $decoded['metadata']['chainedJobs'][0]['data']); + } + + public function testFromArray(): void + { + $data = [ + 'job' => 'job', + 'data' => ['key' => 'value'], + 'metadata' => [ + 'queue' => 'queue', + 'priority' => 'high', + ], + ]; + + $payload = Payload::fromArray($data); + + $this->assertSame('job', $payload->getJob()); + $this->assertSame(['key' => 'value'], $payload->getData()); + $this->assertSame('queue', $payload->getQueue()); + $this->assertSame('high', $payload->getPriority()); + } + + public function testFromArrayWithChainedJobs(): void + { + $data = [ + 'job' => 'job', + 'data' => ['key' => 'value'], + 'metadata' => [ + 'queue' => 'queue', + 'chainedJobs' => [ + [ + 'job' => 'nextJob', + 'data' => ['nextKey' => 'nextValue'], + 'metadata' => ['queue' => 'nextQueue'], + ], + ], + ], + ]; + + $payload = Payload::fromArray($data); + + $this->assertTrue($payload->hasChainedJobs()); + $chainedJobs = $payload->getChainedJobs(); + $this->assertCount(1, $chainedJobs); + + $nextJob = $chainedJobs->shift(); + $this->assertSame('nextJob', $nextJob->getJob()); + $this->assertSame(['nextKey' => 'nextValue'], $nextJob->getData()); + $this->assertSame('nextQueue', $nextJob->getQueue()); + } + + public function testMultipleValidations(): void + { + $payload = new Payload('job', ['key' => 'value']); + + // Test that all validations pass + $payload->setQueue('valid-queue'); + $payload->setPriority('valid-priority'); + $payload->setDelay(30); + + $this->assertSame('valid-queue', $payload->getQueue()); + $this->assertSame('valid-priority', $payload->getPriority()); + $this->assertSame(30, $payload->getDelay()); + } +} diff --git a/tests/PredisHandlerTest.php b/tests/PredisHandlerTest.php index 19ec736..05d79d0 100644 --- a/tests/PredisHandlerTest.php +++ b/tests/PredisHandlerTest.php @@ -1,13 +1,24 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace ThirdParty\queue\tests; use CodeIgniter\I18n\Time; -use CodeIgniter\Test\ReflectionHelper; -use Exception; use CodeIgniter\Queue\Entities\QueueJob; use CodeIgniter\Queue\Exceptions\QueueException; use CodeIgniter\Queue\Handlers\PredisHandler; +use CodeIgniter\Test\ReflectionHelper; +use Exception; use ReflectionException; use Tests\Support\Config\Queue as QueueConfig; use Tests\Support\Database\Seeds\TestRedisQueueSeeder; @@ -30,13 +41,13 @@ protected function setUp(): void $this->config = config(QueueConfig::class); } - public function testPredisHandler() + public function testPredisHandler(): void { $handler = new PredisHandler($this->config); $this->assertInstanceOf(PredisHandler::class, $handler); } - public function testPriority() + public function testPriority(): void { $handler = new PredisHandler($this->config); $handler->setPriority('high'); @@ -44,7 +55,7 @@ public function testPriority() $this->assertSame('high', self::getPrivateProperty($handler, 'priority')); } - public function testPriorityException() + public function testPriorityException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('The priority name should consists only lowercase letters.'); @@ -56,12 +67,12 @@ public function testPriorityException() /** * @throws ReflectionException */ - public function testPush() + public function testPush(): void { $handler = new PredisHandler($this->config); $result = $handler->push('queue', 'success', ['key' => 'value']); - $this->assertTrue($result); + $this->assertTrue($result->getStatus()); $predis = self::getPrivateProperty($handler, 'predis'); $this->assertSame(1, $predis->zcard('queues:queue:low')); @@ -70,17 +81,18 @@ public function testPush() $queueJob = new QueueJob(json_decode((string) $task[0], true)); $this->assertSame('success', $queueJob->payload['job']); $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); } /** * @throws ReflectionException */ - public function testPushWithPriority() + public function testPushWithPriority(): void { $handler = new PredisHandler($this->config); $result = $handler->setPriority('high')->push('queue', 'success', ['key' => 'value']); - $this->assertTrue($result); + $this->assertTrue($result->getStatus()); $predis = self::getPrivateProperty($handler, 'predis'); $this->assertSame(1, $predis->zcard('queues:queue:high')); @@ -89,9 +101,117 @@ public function testPushWithPriority() $queueJob = new QueueJob(json_decode((string) $task[0], true)); $this->assertSame('success', $queueJob->payload['job']); $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); + } + + /** + * @throws ReflectionException + */ + public function testPushWithDelay(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new PredisHandler($this->config); + $result = $handler->setDelay(MINUTE)->push('queue-delay', 'success', ['key' => 'value']); + + $this->assertTrue($result->getStatus()); + + $predis = self::getPrivateProperty($handler, 'predis'); + $this->assertSame(1, $predis->zcard('queues:queue-delay:default')); + + $task = $predis->zrangebyscore('queues:queue-delay:default', '-inf', Time::now()->addSeconds(MINUTE)->timestamp, ['limit' => [0, 1]]); + $queueJob = new QueueJob(json_decode((string) $task[0], true)); + $this->assertSame('success', $queueJob->payload['job']); + $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); + } + + /** + * @throws Exception + */ + public function testChain(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new PredisHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->push('queue', 'success', ['key2' => 'value2']); + }); + + $this->assertTrue($result->getStatus()); + + $predis = self::getPrivateProperty($handler, 'predis'); + $this->assertSame(1, $predis->zcard('queues:queue:low')); + + $task = $predis->zrangebyscore('queues:queue:low', '-inf', Time::now()->timestamp, ['limit' => [0, 1]]); + $job = new QueueJob(json_decode((string) $task[0], true)); + + $this->assertSame('success', $job->payload['job']); + $this->assertSame(['key1' => 'value1'], $job->payload['data']); + $this->assertArrayHasKey('metadata', $job->payload); + $this->assertArrayHasKey('queue', $job->payload['metadata']); + $this->assertSame('queue', $job->payload['metadata']['queue']); + $this->assertArrayHasKey('chainedJobs', $job->payload['metadata']); + + $chainedJobs = $job->payload['metadata']['chainedJobs']; + $this->assertCount(1, $chainedJobs); + $this->assertSame('success', $chainedJobs[0]['job']); + $this->assertSame(['key2' => 'value2'], $chainedJobs[0]['data']); + $this->assertSame('queue', $chainedJobs[0]['metadata']['queue']); + } + + /** + * @throws Exception + */ + public function testChainWithPriorityAndDelay(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new PredisHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->setPriority('high') + ->setDelay(60) + ->push('queue', 'success', ['key2' => 'value2']) + ->setPriority('low') + ->setDelay(120); + }); + + $this->assertTrue($result->getStatus()); + + $predis = self::getPrivateProperty($handler, 'predis'); + // Should be in high priority queue + $this->assertSame(1, $predis->zcard('queues:queue:high')); + + // Check with delay + $task = $predis->zrangebyscore('queues:queue:high', '-inf', Time::now()->addSeconds(61)->timestamp, ['limit' => [0, 1]]); + $queueJob = new QueueJob(json_decode((string) $task[0], true)); + + $this->assertSame('success', $queueJob->payload['job']); + $this->assertSame(['key1' => 'value1'], $queueJob->payload['data']); + $this->assertArrayHasKey('metadata', $queueJob->payload); + + // Check metadata + $meta = $queueJob->payload['metadata']; + $this->assertSame('queue', $meta['queue']); + $this->assertSame('high', $meta['priority']); + $this->assertSame(60, $meta['delay']); + + // Check a chained job with its priority and delay + $this->assertArrayHasKey('chainedJobs', $meta); + $chainedJobs = $meta['chainedJobs']; + $this->assertCount(1, $chainedJobs); + $this->assertSame('success', $chainedJobs[0]['job']); + $this->assertSame(['key2' => 'value2'], $chainedJobs[0]['data']); + $this->assertSame('queue', $chainedJobs[0]['metadata']['queue']); + $this->assertSame('low', $chainedJobs[0]['metadata']['priority']); + $this->assertSame(120, $chainedJobs[0]['metadata']['delay']); } - public function testPushException() + public function testPushException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('This job name is not defined in the $jobHandlers array.'); @@ -100,7 +220,7 @@ public function testPushException() $handler->push('queue', 'not-exists', ['key' => 'value']); } - public function testPushWithPriorityException() + public function testPushWithPriorityException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('This queue has incorrectly defined priority: "invalid" for the queue: "queue".'); @@ -112,7 +232,7 @@ public function testPushWithPriorityException() /** * @throws ReflectionException */ - public function testPop() + public function testPop(): void { $handler = new PredisHandler($this->config); $result = $handler->pop('queue1', ['default']); @@ -125,7 +245,7 @@ public function testPop() $this->assertSame(1, $predis->hexists('queues:queue1::reserved', $result->id)); } - public function testPopEmpty() + public function testPopEmpty(): void { $handler = new PredisHandler($this->config); $result = $handler->pop('queue123', ['default']); @@ -136,7 +256,7 @@ public function testPopEmpty() /** * @throws ReflectionException */ - public function testLater() + public function testLater(): void { $handler = new PredisHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -155,7 +275,7 @@ public function testLater() /** * @throws ReflectionException */ - public function testFailedAndKeepJob() + public function testFailedAndKeepJob(): void { $handler = new PredisHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -171,7 +291,7 @@ public function testFailedAndKeepJob() $this->seeInDatabase('queue_jobs_failed', [ 'id' => 2, - 'connection' => 'database', + 'connection' => 'predis', 'queue' => 'queue1', ]); } @@ -179,7 +299,7 @@ public function testFailedAndKeepJob() /** * @throws ReflectionException */ - public function testFailedAndDontKeepJob() + public function testFailedAndDontKeepJob(): void { $handler = new PredisHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -195,7 +315,7 @@ public function testFailedAndDontKeepJob() $this->dontSeeInDatabase('queue_jobs_failed', [ 'id' => 2, - 'connection' => 'database', + 'connection' => 'predis', 'queue' => 'queue1', ]); } @@ -203,7 +323,7 @@ public function testFailedAndDontKeepJob() /** * @throws ReflectionException */ - public function testDoneAndKeepJob() + public function testDoneAndKeepJob(): void { $handler = new PredisHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -220,7 +340,7 @@ public function testDoneAndKeepJob() /** * @throws ReflectionException */ - public function testDoneAndDontKeepJob() + public function testDoneAndDontKeepJob(): void { $handler = new PredisHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -238,7 +358,7 @@ public function testDoneAndDontKeepJob() /** * @throws ReflectionException */ - public function testClear() + public function testClear(): void { $handler = new PredisHandler($this->config); $result = $handler->clear('queue1'); @@ -255,7 +375,7 @@ public function testClear() /** * @throws ReflectionException */ - public function testClearAll() + public function testClearAll(): void { $handler = new PredisHandler($this->config); $result = $handler->clear(); @@ -272,12 +392,12 @@ public function testClearAll() /** * @throws ReflectionException */ - public function testRetry() + public function testRetry(): void { $handler = new PredisHandler($this->config); $count = $handler->retry(1, 'queue1'); - $this->assertSame($count, 1); + $this->assertSame(1, $count); $predis = self::getPrivateProperty($handler, 'predis'); $this->assertSame(2, $predis->zcard('queues:queue1:default')); diff --git a/tests/PushAndPopWithDelayTest.php b/tests/PushAndPopWithDelayTest.php new file mode 100644 index 0000000..f06b0f9 --- /dev/null +++ b/tests/PushAndPopWithDelayTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests; + +use CodeIgniter\I18n\Time; +use CodeIgniter\Queue\Entities\QueueJob; +use CodeIgniter\Test\ReflectionHelper; +use PHPUnit\Framework\Attributes\DataProvider; +use Tests\Support\Config\Queue as QueueConfig; +use Tests\Support\Database\Seeds\TestDatabaseQueueSeeder; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class PushAndPopWithDelayTest extends TestCase +{ + use ReflectionHelper; + + protected $seed = TestDatabaseQueueSeeder::class; + private QueueConfig $config; + + protected function setUp(): void + { + parent::setUp(); + + $this->config = config(QueueConfig::class); + } + + #[DataProvider('providePushAndPopWithDelay')] + public function testPushAndPopWithDelay(string $name, string $class): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new $class($this->config); + $result = $handler->setDelay(MINUTE)->push('queue-delay', 'success', ['key1' => 'value1']); + + $this->assertNotNull($result); + + $result = $handler->push('queue-delay', 'success', ['key2' => 'value2']); + + $this->assertNotNull($result); + + if ($name === 'database') { + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue-delay', + 'payload' => json_encode(['job' => 'success', 'data' => ['key1' => 'value1'], 'metadata' => []]), + 'available_at' => 1703859376, + ]); + + $this->seeInDatabase('queue_jobs', [ + 'queue' => 'queue-delay', + 'payload' => json_encode(['job' => 'success', 'data' => ['key2' => 'value2'], 'metadata' => []]), + 'available_at' => 1703859316, + ]); + } + + $result = $handler->pop('queue-delay', ['default']); + $this->assertInstanceOf(QueueJob::class, $result); + $payload = ['job' => 'success', 'data' => ['key2' => 'value2'], 'metadata' => []]; + $this->assertSame($payload, $result->payload); + + $result = $handler->pop('queue-delay', ['default']); + $this->assertNull($result); + + // add 1 minute + Time::setTestNow('2023-12-29 14:16:16'); + + $result = $handler->pop('queue-delay', ['default']); + $this->assertInstanceOf(QueueJob::class, $result); + $payload = ['job' => 'success', 'data' => ['key1' => 'value1'], 'metadata' => []]; + $this->assertSame($payload, $result->payload); + } + + public static function providePushAndPopWithDelay(): iterable + { + return [ + [ + 'database', // name + 'CodeIgniter\Queue\Handlers\DatabaseHandler', // class + ], + [ + 'redis', + 'CodeIgniter\Queue\Handlers\RedisHandler', + ], + [ + 'predis', + 'CodeIgniter\Queue\Handlers\PredisHandler', + ], + ]; + } +} diff --git a/tests/QueuePushResultTest.php b/tests/QueuePushResultTest.php new file mode 100644 index 0000000..f5a6dfa --- /dev/null +++ b/tests/QueuePushResultTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests; + +use CodeIgniter\Queue\QueuePushResult; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class QueuePushResultTest extends TestCase +{ + public function testConstructorSuccess(): void + { + $result = new QueuePushResult(true, 123456, null); + + $this->assertTrue($result->getStatus()); + $this->assertSame(123456, $result->getJobId()); + $this->assertNull($result->getError()); + } + + public function testConstructorFailure(): void + { + $result = new QueuePushResult(false, null, 'Something went wrong'); + + $this->assertFalse($result->getStatus()); + $this->assertNull($result->getJobId()); + $this->assertSame('Something went wrong', $result->getError()); + } + + public function testStaticSuccess(): void + { + $result = QueuePushResult::success(999888); + + $this->assertTrue($result->getStatus()); + $this->assertSame(999888, $result->getJobId()); + $this->assertNull($result->getError()); + } + + public function testStaticFailure(): void + { + $result = QueuePushResult::failure('Redis error'); + + $this->assertFalse($result->getStatus()); + $this->assertNull($result->getJobId()); + $this->assertSame('Redis error', $result->getError()); + } + + public function testStaticFailureWithoutError(): void + { + $result = QueuePushResult::failure(); + + $this->assertFalse($result->getStatus()); + $this->assertNull($result->getJobId()); + $this->assertNull($result->getError()); + } +} diff --git a/tests/QueueTest.php b/tests/QueueTest.php index 0bd655b..7daa4d4 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace Tests; use CodeIgniter\Queue\Exceptions\QueueException; @@ -24,13 +35,13 @@ protected function setUp(): void $this->config = config(QueueConfig::class); } - public function testQueue() + public function testQueue(): void { $queue = new Queue($this->config); $this->assertInstanceOf(Queue::class, $queue); } - public function testQueueException() + public function testQueueException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('This queue handler is incorrect.'); @@ -41,7 +52,7 @@ public function testQueueException() $this->assertInstanceOf(Queue::class, $queue); } - public function testQueueInit() + public function testQueueInit(): void { $queue = new Queue($this->config); $this->assertInstanceOf(DatabaseHandler::class, $queue->init()); diff --git a/tests/RabbitMQDelayTest.php b/tests/RabbitMQDelayTest.php new file mode 100644 index 0000000..47232fe --- /dev/null +++ b/tests/RabbitMQDelayTest.php @@ -0,0 +1,166 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests; + +use CodeIgniter\Exceptions\CriticalError; +use CodeIgniter\Queue\Entities\QueueJob; +use CodeIgniter\Queue\Handlers\RabbitMQHandler; +use CodeIgniter\Queue\QueuePushResult; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use Tests\Support\Config\Queue as QueueConfig; +use Tests\Support\TestCase; +use Throwable; + +/** + * Test RabbitMQ delay functionality with real timing. + * + * @internal + */ +final class RabbitMQDelayTest extends TestCase +{ + private ?RabbitMQHandler $handler = null; + + protected function setUp(): void + { + parent::setUp(); + + $config = config(QueueConfig::class); + + // Skip tests if RabbitMQ is not available + if (! $this->isRabbitMQAvailable()) { + $this->markTestSkipped('RabbitMQ is not available for testing'); + } + + try { + $this->handler = new RabbitMQHandler($config); + } catch (CriticalError) { + $this->markTestSkipped('Cannot connect to RabbitMQ server'); + } + } + + protected function tearDown(): void + { + if ($this->handler !== null) { + // Clear test queues + try { + $this->handler->clear('delay-test-queue'); + } catch (Throwable) { + // Ignore cleanup errors + } + } + + parent::tearDown(); + } + + public function testDelayedMessageWithRealTiming(): void + { + // Use a short real delay (2 seconds) for testing + $delaySeconds = 2; + $startTime = time(); + + // Push a delayed job + $result = $this->handler->setDelay($delaySeconds)->push('delay-test-queue', 'success', ['type' => 'delayed']); + $this->assertInstanceOf(QueuePushResult::class, $result); + $this->assertTrue($result->getStatus()); + + // Push an immediate job + $result = $this->handler->push('delay-test-queue', 'success', ['type' => 'immediate']); + $this->assertInstanceOf(QueuePushResult::class, $result); + $this->assertTrue($result->getStatus()); + + // Should get immediate job first + $job = $this->handler->pop('delay-test-queue', ['default']); + $this->assertInstanceOf(QueueJob::class, $job); + $this->assertSame('immediate', $job->payload['data']['type']); + $this->handler->done($job, false); + + // Should not get delayed job yet (within first second) + $job = $this->handler->pop('delay-test-queue', ['default']); + $this->assertNull($job); + + // Wait for delay to expire (with a small buffer) + $waitTime = $delaySeconds + 1; + sleep($waitTime); + + // Should now get the delayed job + $job = $this->handler->pop('delay-test-queue', ['default']); + $this->assertInstanceOf(QueueJob::class, $job); + $this->assertSame('delayed', $job->payload['data']['type']); + + // Verify timing - job should have been delayed at least the specified time + $elapsedTime = time() - $startTime; + $this->assertGreaterThanOrEqual($delaySeconds, $elapsedTime); + + // Clean up + $this->handler->done($job, false); + } + + public function testMultipleDelayedJobsWithDifferentDelays(): void + { + // Push jobs with different delays + $result1 = $this->handler->setDelay(1)->push('delay-test-queue', 'success', ['order' => 'first', 'delay' => 1]); + $result2 = $this->handler->setDelay(3)->push('delay-test-queue', 'success', ['order' => 'second', 'delay' => 3]); + $result3 = $this->handler->push('delay-test-queue', 'success', ['order' => 'immediate', 'delay' => 0]); + + $this->assertTrue($result1->getStatus()); + $this->assertTrue($result2->getStatus()); + $this->assertTrue($result3->getStatus()); + + // Should get immediate job first + $job = $this->handler->pop('delay-test-queue', ['default']); + $this->assertInstanceOf(QueueJob::class, $job); + $this->assertSame('immediate', $job->payload['data']['order']); + $this->handler->done($job, false); + + // Wait 2 seconds - should get first delayed job + sleep(2); + $job = $this->handler->pop('delay-test-queue', ['default']); + $this->assertInstanceOf(QueueJob::class, $job); + $this->assertSame('first', $job->payload['data']['order']); + $this->handler->done($job, false); + + // Should not get second job yet + $job = $this->handler->pop('delay-test-queue', ['default']); + $this->assertNull($job); + + // Wait another 2 seconds - should get second delayed job + sleep(2); + $job = $this->handler->pop('delay-test-queue', ['default']); + $this->assertInstanceOf(QueueJob::class, $job); + $this->assertSame('second', $job->payload['data']['order']); + $this->handler->done($job, false); + } + + public function testZeroDelayWorksImmediately(): void + { + // Jobs with 0 delay should work immediately + $result = $this->handler->setDelay(0)->push('delay-test-queue', 'success', ['type' => 'zero-delay']); + $this->assertTrue($result->getStatus()); + + // Should be able to pop immediately + $job = $this->handler->pop('delay-test-queue', ['default']); + $this->assertInstanceOf(QueueJob::class, $job); + $this->assertSame('zero-delay', $job->payload['data']['type']); + + $this->handler->done($job, false); + } + + /** + * Check if RabbitMQ is available for testing. + */ + private function isRabbitMQAvailable(): bool + { + return class_exists(AMQPStreamConnection::class); + } +} diff --git a/tests/RabbitMQHandlerTest.php b/tests/RabbitMQHandlerTest.php new file mode 100644 index 0000000..2836f2c --- /dev/null +++ b/tests/RabbitMQHandlerTest.php @@ -0,0 +1,402 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests; + +use CodeIgniter\Exceptions\CriticalError; +use CodeIgniter\Queue\Entities\QueueJob; +use CodeIgniter\Queue\Enums\Status; +use CodeIgniter\Queue\Exceptions\QueueException; +use CodeIgniter\Queue\Handlers\RabbitMQHandler; +use CodeIgniter\Queue\QueuePushResult; +use CodeIgniter\Test\ReflectionHelper; +use Exception; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use Tests\Support\Config\Queue as QueueConfig; +use Tests\Support\TestCase; +use Throwable; + +/** + * @internal + */ +final class RabbitMQHandlerTest extends TestCase +{ + use ReflectionHelper; + + private QueueConfig $config; + private ?RabbitMQHandler $handler = null; + + protected function setUp(): void + { + parent::setUp(); + + $this->config = config(QueueConfig::class); + + // Skip tests if RabbitMQ is not available + if (! $this->isRabbitMQAvailable()) { + $this->markTestSkipped('RabbitMQ is not available for testing'); + } + + try { + $this->handler = new RabbitMQHandler($this->config); + } catch (CriticalError) { + $this->markTestSkipped('Cannot connect to RabbitMQ server'); + } + } + + protected function tearDown(): void + { + if ($this->handler !== null) { + // Clear test queues + try { + $this->handler->clear('test-queue'); + $this->handler->clear('test-queue-1'); + $this->handler->clear('test-queue-2'); + $this->handler->clear('priority-test'); + $this->handler->clear('custom-priority-queue'); + } catch (Throwable) { + // Ignore cleanup errors + } + } + + parent::tearDown(); + } + + public function testRabbitMQHandler(): void + { + $this->assertInstanceOf(RabbitMQHandler::class, $this->handler); + $this->assertSame('rabbitmq', $this->handler->name()); + } + + public function testRabbitMQConnectionFailure(): void + { + $this->expectException(CriticalError::class); + $this->expectExceptionMessage('Queue: RabbitMQ connection failed.'); + + $badConfig = clone $this->config; + $badConfig->rabbitmq['host'] = 'nonexistent-host'; + $badConfig->rabbitmq['port'] = 12345; + + new RabbitMQHandler($badConfig); + } + + public function testPushJob(): void + { + $result = $this->handler->push('test-queue', 'success', ['message' => 'Hello World']); + + $this->assertInstanceOf(QueuePushResult::class, $result); + $this->assertTrue($result->getStatus()); + $this->assertIsInt($result->getJobId()); + $this->assertNull($result->getError()); + } + + public function testPushJobWithDelay(): void + { + $result = $this->handler->setDelay(30)->push('test-queue', 'success', ['message' => 'Delayed']); + + $this->assertInstanceOf(QueuePushResult::class, $result); + $this->assertTrue($result->getStatus()); + } + + public function testPushJobWithPriority(): void + { + $this->config->queuePriorities['priority-test'] = ['high', 'default', 'low']; + + $result = $this->handler->setPriority('high')->push('priority-test', 'success', ['priority' => 'high']); + + $this->assertTrue($result->getStatus()); + } + + public function testPopJob(): void + { + $this->handler->push('test-queue', 'success', ['message' => 'Test Pop']); + + // Give RabbitMQ a moment to process + usleep(100_000); + + $job = $this->handler->pop('test-queue', ['default']); + + if ($job !== null) { + $this->assertInstanceOf(QueueJob::class, $job); + $this->assertSame('test-queue', $job->queue); + $this->assertSame('success', $job->payload['job']); + $this->assertSame(['message' => 'Test Pop'], $job->payload['data']); + + // Clean up - mark as done + $this->handler->done($job, false); + } + } + + public function testPopJobWithPriorities(): void + { + $this->config->queuePriorities['priority-test'] = ['high', 'default', 'low']; + + // Push jobs with different priorities + $this->handler->setPriority('low')->push('priority-test', 'success', ['priority' => 'low']); + $this->handler->setPriority('high')->push('priority-test', 'success', ['priority' => 'high']); + $this->handler->setPriority('default')->push('priority-test', 'success', ['priority' => 'default']); + + usleep(100_000); + + // Should get high priority job first + $job = $this->handler->pop('priority-test', ['high', 'default', 'low']); + + if ($job !== null) { + $this->assertSame('high', $job->priority); + $this->handler->done($job, false); + } + } + + public function testJobFailure(): void + { + $this->handler->push('test-queue', 'failure', ['message' => 'Will Fail']); + + usleep(100_000); + + $job = $this->handler->pop('test-queue', ['default']); + + if ($job !== null) { + $exception = new Exception('Test failure'); + $result = $this->handler->failed($job, $exception, false); + + $this->assertTrue($result); + } + } + + public function testJobLater(): void + { + $this->handler->push('test-queue', 'success', ['message' => 'Reschedule']); + + usleep(100_000); + + $job = $this->handler->pop('test-queue', ['default']); + + if ($job !== null) { + $result = $this->handler->later($job, 60); + $this->assertTrue($result); + } + } + + public function testClearQueue(): void + { + $this->handler->push('test-queue', 'success', ['message' => 'Clear Test 1']); + $this->handler->push('test-queue', 'success', ['message' => 'Clear Test 2']); + + usleep(100_000); + + $result = $this->handler->clear('test-queue'); + $this->assertTrue($result); + + // Verify queue is empty + $job = $this->handler->pop('test-queue', ['default']); + $this->assertNull($job); + } + + public function testIncorrectJobHandler(): void + { + $this->expectException(QueueException::class); + + $this->handler->push('test-queue', 'nonexistent-job', []); + } + + public function testIncorrectQueueFormat(): void + { + $this->expectException(QueueException::class); + + $this->handler->push('invalid queue name!', 'success', []); + } + + public function testIncorrectPriority(): void + { + $this->expectException(QueueException::class); + + $this->config->queuePriorities['test-queue'] = ['high', 'low']; + + $this->handler->setPriority('medium')->push('test-queue', 'success', []); + } + + public function testCustomPriorityMapping(): void + { + // Define custom priorities for a queue + $this->config->queuePriorities['custom-priority-queue'] = ['urgent', 'normal', 'low']; + + // Test that we can push jobs with custom priorities + $result1 = $this->handler->setPriority('urgent')->push('custom-priority-queue', 'success', ['priority' => 'urgent']); + $result2 = $this->handler->setPriority('normal')->push('custom-priority-queue', 'success', ['priority' => 'normal']); + $result3 = $this->handler->setPriority('low')->push('custom-priority-queue', 'success', ['priority' => 'low']); + + $this->assertTrue($result1->getStatus()); + $this->assertTrue($result2->getStatus()); + $this->assertTrue($result3->getStatus()); + + usleep(100_000); + + // Should get urgent priority job first + $job = $this->handler->pop('custom-priority-queue', ['urgent', 'normal', 'low']); + if ($job !== null) { + $this->assertSame('urgent', $job->payload['data']['priority']); + $this->handler->done($job, false); + } + + // Then normal priority + $job = $this->handler->pop('custom-priority-queue', ['urgent', 'normal', 'low']); + if ($job !== null) { + $this->assertSame('normal', $job->payload['data']['priority']); + $this->handler->done($job, false); + } + + // Finally low priority + $job = $this->handler->pop('custom-priority-queue', ['urgent', 'normal', 'low']); + if ($job !== null) { + $this->assertSame('low', $job->payload['data']['priority']); + $this->handler->done($job, false); + } + } + + public function testPriority(): void + { + $this->handler->setPriority('high'); + + $this->assertSame('high', self::getPrivateProperty($this->handler, 'priority')); + } + + public function testPriorityException(): void + { + $this->expectException(QueueException::class); + $this->expectExceptionMessage('The priority name should consists only lowercase letters.'); + + $this->handler->setPriority('high_:'); + } + + public function testPopEmpty(): void + { + $result = $this->handler->pop('empty-queue', ['default']); + + $this->assertNull($result); + } + + public function testFailedAndKeepJob(): void + { + $this->handler->push('test-queue', 'success', ['test' => 'data']); + $queueJob = $this->handler->pop('test-queue', ['default']); + + $this->assertInstanceOf(QueueJob::class, $queueJob); + + $err = new Exception('Sample exception'); + $result = $this->handler->failed($queueJob, $err, true); + + $this->assertTrue($result); + + $this->seeInDatabase('queue_jobs_failed', [ + 'queue' => 'test-queue', + 'connection' => 'rabbitmq', + ]); + } + + public function testFailedAndDontKeepJob(): void + { + $this->handler->push('test-queue', 'success', ['test' => 'data']); + $queueJob = $this->handler->pop('test-queue', ['default']); + + $this->assertInstanceOf(QueueJob::class, $queueJob); + + $err = new Exception('Sample exception'); + $result = $this->handler->failed($queueJob, $err, false); + + $this->assertTrue($result); + + $this->dontSeeInDatabase('queue_jobs_failed', [ + 'queue' => 'test-queue', + 'connection' => 'rabbitmq', + ]); + } + + public function testDoneAndKeepJob(): void + { + $this->handler->push('test-queue', 'success', ['test' => 'data']); + $queueJob = $this->handler->pop('test-queue', ['default']); + + $this->assertInstanceOf(QueueJob::class, $queueJob); + + $result = $this->handler->done($queueJob, true); + + $this->assertTrue($result); + $this->assertSame(Status::DONE->value, $queueJob->status); + } + + public function testDoneAndDontKeepJob(): void + { + $this->handler->push('test-queue', 'success', ['test' => 'data']); + $queueJob = $this->handler->pop('test-queue', ['default']); + + $this->assertInstanceOf(QueueJob::class, $queueJob); + + $result = $this->handler->done($queueJob, false); + + // Job is acknowledged and removed from RabbitMQ + $this->assertTrue($result); + } + + public function testClearAll(): void + { + $this->handler->push('test-queue-1', 'success', ['test' => 'data1']); + $this->handler->push('test-queue-2', 'success', ['test' => 'data2']); + + usleep(100_000); + + $job1 = $this->handler->pop('test-queue-1', ['default']); + $job2 = $this->handler->pop('test-queue-2', ['default']); + + $this->assertInstanceOf(QueueJob::class, $job1); + $this->assertInstanceOf(QueueJob::class, $job2); + + // Put jobs back by rejecting them + if (isset($job1->amqpDeliveryTag)) { + $channel = self::getPrivateProperty($this->handler, 'channel'); + $channel->basic_nack($job1->amqpDeliveryTag, false, true); // requeue=true + } + if (isset($job2->amqpDeliveryTag)) { + $channel = self::getPrivateProperty($this->handler, 'channel'); + $channel->basic_nack($job2->amqpDeliveryTag, false, true); + } + + usleep(100_000); + + // Clear all queues + $result = $this->handler->clear(); + $this->assertTrue($result); + + // Verify queues are empty by attempting to pop + $jobAfter1 = $this->handler->pop('test-queue-1', ['default']); + $jobAfter2 = $this->handler->pop('test-queue-2', ['default']); + + $this->assertNull($jobAfter1); + $this->assertNull($jobAfter2); + } + + public function testJsonEncodeExceptionMethod(): void + { + $exception = QueueException::forFailedJsonEncode('Malformed UTF-8 characters'); + + $this->assertInstanceOf(QueueException::class, $exception); + $this->assertStringContainsString('Failed to JSON encode queue job: Malformed UTF-8 characters', $exception->getMessage()); + } + + /** + * Check if RabbitMQ is available for testing. + */ + private function isRabbitMQAvailable(): bool + { + return class_exists(AMQPStreamConnection::class); + } +} diff --git a/tests/RedisHandlerTest.php b/tests/RedisHandlerTest.php index 60b57e5..7707cd4 100644 --- a/tests/RedisHandlerTest.php +++ b/tests/RedisHandlerTest.php @@ -1,13 +1,25 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace Tests; use CodeIgniter\I18n\Time; -use CodeIgniter\Test\ReflectionHelper; -use Exception; use CodeIgniter\Queue\Entities\QueueJob; use CodeIgniter\Queue\Exceptions\QueueException; use CodeIgniter\Queue\Handlers\RedisHandler; +use CodeIgniter\Test\ReflectionHelper; +use Exception; +use ReflectionException; use Tests\Support\Config\Queue as QueueConfig; use Tests\Support\Database\Seeds\TestRedisQueueSeeder; use Tests\Support\TestCase; @@ -29,13 +41,13 @@ protected function setUp(): void $this->config = config(QueueConfig::class); } - public function testRedisHandler() + public function testRedisHandler(): void { $handler = new RedisHandler($this->config); $this->assertInstanceOf(RedisHandler::class, $handler); } - public function testPriority() + public function testPriority(): void { $handler = new RedisHandler($this->config); $handler->setPriority('high'); @@ -43,7 +55,7 @@ public function testPriority() $this->assertSame('high', self::getPrivateProperty($handler, 'priority')); } - public function testPriorityException() + public function testPriorityException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('The priority name should consists only lowercase letters.'); @@ -52,12 +64,12 @@ public function testPriorityException() $handler->setPriority('high_:'); } - public function testPush() + public function testPush(): void { $handler = new RedisHandler($this->config); $result = $handler->push('queue', 'success', ['key' => 'value']); - $this->assertTrue($result); + $this->assertTrue($result->getStatus()); $redis = self::getPrivateProperty($handler, 'redis'); $this->assertSame(1, $redis->zCard('queues:queue:low')); @@ -66,14 +78,15 @@ public function testPush() $queueJob = new QueueJob(json_decode((string) $task[0], true)); $this->assertSame('success', $queueJob->payload['job']); $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); } - public function testPushWithPriority() + public function testPushWithPriority(): void { $handler = new RedisHandler($this->config); $result = $handler->setPriority('high')->push('queue', 'success', ['key' => 'value']); - $this->assertTrue($result); + $this->assertTrue($result->getStatus()); $redis = self::getPrivateProperty($handler, 'redis'); $this->assertSame(1, $redis->zCard('queues:queue:high')); @@ -82,9 +95,117 @@ public function testPushWithPriority() $queueJob = new QueueJob(json_decode((string) $task[0], true)); $this->assertSame('success', $queueJob->payload['job']); $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); + } + + /** + * @throws ReflectionException + */ + public function testPushWithDelay(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new RedisHandler($this->config); + $result = $handler->setDelay(MINUTE)->push('queue-delay', 'success', ['key' => 'value']); + + $this->assertTrue($result->getStatus()); + + $redis = self::getPrivateProperty($handler, 'redis'); + $this->assertSame(1, $redis->zCard('queues:queue-delay:default')); + + $task = $redis->zRangeByScore('queues:queue-delay:default', '-inf', Time::now()->addSeconds(MINUTE)->timestamp, ['limit' => [0, 1]]); + $queueJob = new QueueJob(json_decode((string) $task[0], true)); + $this->assertSame('success', $queueJob->payload['job']); + $this->assertSame(['key' => 'value'], $queueJob->payload['data']); + $this->assertSame([], $queueJob->payload['metadata']); + } + + /** + * @throws Exception + */ + public function testChain(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new RedisHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->push('queue', 'success', ['key2' => 'value2']); + }); + + $this->assertTrue($result->getStatus()); + + $redis = self::getPrivateProperty($handler, 'redis'); + $this->assertSame(1, $redis->zCard('queues:queue:low')); + + $task = $redis->zRangeByScore('queues:queue:low', '-inf', Time::now()->timestamp, ['limit' => [0, 1]]); + $queueJob = new QueueJob(json_decode((string) $task[0], true)); + + $this->assertSame('success', $queueJob->payload['job']); + $this->assertSame(['key1' => 'value1'], $queueJob->payload['data']); + $this->assertArrayHasKey('metadata', $queueJob->payload); + $this->assertArrayHasKey('queue', $queueJob->payload['metadata']); + $this->assertSame('queue', $queueJob->payload['metadata']['queue']); + $this->assertArrayHasKey('chainedJobs', $queueJob->payload['metadata']); + + $chainedJobs = $queueJob->payload['metadata']['chainedJobs']; + $this->assertCount(1, $chainedJobs); + $this->assertSame('success', $chainedJobs[0]['job']); + $this->assertSame(['key2' => 'value2'], $chainedJobs[0]['data']); + $this->assertSame('queue', $chainedJobs[0]['metadata']['queue']); + } + + /** + * @throws Exception + */ + public function testChainWithPriorityAndDelay(): void + { + Time::setTestNow('2023-12-29 14:15:16'); + + $handler = new RedisHandler($this->config); + $result = $handler->chain(static function ($chain): void { + $chain + ->push('queue', 'success', ['key1' => 'value1']) + ->setPriority('high') + ->setDelay(60) + ->push('queue', 'success', ['key2' => 'value2']) + ->setPriority('low') + ->setDelay(120); + }); + + $this->assertTrue($result->getStatus()); + + $redis = self::getPrivateProperty($handler, 'redis'); + // Should be in high priority queue + $this->assertSame(1, $redis->zCard('queues:queue:high')); + + // Check with delay + $task = $redis->zRangeByScore('queues:queue:high', '-inf', Time::now()->addSeconds(61)->timestamp, ['limit' => [0, 1]]); + $queueJob = new QueueJob(json_decode((string) $task[0], true)); + + $this->assertSame('success', $queueJob->payload['job']); + $this->assertSame(['key1' => 'value1'], $queueJob->payload['data']); + $this->assertArrayHasKey('metadata', $queueJob->payload); + + // Check metadata + $metadata = $queueJob->payload['metadata']; + $this->assertSame('queue', $metadata['queue']); + $this->assertSame('high', $metadata['priority']); + $this->assertSame(60, $metadata['delay']); + + // Check a chained job with its priority and delay + $this->assertArrayHasKey('chainedJobs', $metadata); + $chainedJobs = $metadata['chainedJobs']; + $this->assertCount(1, $chainedJobs); + $this->assertSame('success', $chainedJobs[0]['job']); + $this->assertSame(['key2' => 'value2'], $chainedJobs[0]['data']); + $this->assertSame('queue', $chainedJobs[0]['metadata']['queue']); + $this->assertSame('low', $chainedJobs[0]['metadata']['priority']); + $this->assertSame(120, $chainedJobs[0]['metadata']['delay']); } - public function testPushException() + public function testPushException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('This job name is not defined in the $jobHandlers array.'); @@ -93,7 +214,7 @@ public function testPushException() $handler->push('queue', 'not-exists', ['key' => 'value']); } - public function testPushWithPriorityException() + public function testPushWithPriorityException(): void { $this->expectException(QueueException::class); $this->expectExceptionMessage('This queue has incorrectly defined priority: "invalid" for the queue: "queue".'); @@ -102,7 +223,7 @@ public function testPushWithPriorityException() $handler->setPriority('invalid')->push('queue', 'success', ['key' => 'value']); } - public function testPop() + public function testPop(): void { $handler = new RedisHandler($this->config); $result = $handler->pop('queue1', ['default']); @@ -112,10 +233,10 @@ public function testPop() $redis = self::getPrivateProperty($handler, 'redis'); $this->assertSame(1_234_567_890_654_321, $result->id); $this->assertSame(0, $redis->zCard('queues:queue1:default')); - $this->assertTrue($redis->hExists('queues:queue1::reserved', $result->id)); + $this->assertTrue($redis->hExists('queues:queue1::reserved', (string) $result->id)); } - public function testPopEmpty() + public function testPopEmpty(): void { $handler = new RedisHandler($this->config); $result = $handler->pop('queue123', ['default']); @@ -123,23 +244,23 @@ public function testPopEmpty() $this->assertNull($result); } - public function testLater() + public function testLater(): void { $handler = new RedisHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); $redis = self::getPrivateProperty($handler, 'redis'); - $this->assertTrue($redis->hExists('queues:queue1::reserved', $queueJob->id)); + $this->assertTrue($redis->hExists('queues:queue1::reserved', (string) $queueJob->id)); $this->assertSame(0, $redis->zCard('queues:queue1:default')); $result = $handler->later($queueJob, 60); $this->assertTrue($result); - $this->assertFalse($redis->hExists('queues:queue1::reserved', $queueJob->id)); + $this->assertFalse($redis->hExists('queues:queue1::reserved', (string) $queueJob->id)); $this->assertSame(1, $redis->zCard('queues:queue1:default')); } - public function testFailedAndKeepJob() + public function testFailedAndKeepJob(): void { $handler = new RedisHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -150,17 +271,17 @@ public function testFailedAndKeepJob() $redis = self::getPrivateProperty($handler, 'redis'); $this->assertTrue($result); - $this->assertFalse($redis->hExists('queues:queue1::reserved', $queueJob->id)); + $this->assertFalse($redis->hExists('queues:queue1::reserved', (string) $queueJob->id)); $this->assertSame(0, $redis->zCard('queues:queue1:default')); $this->seeInDatabase('queue_jobs_failed', [ 'id' => 2, - 'connection' => 'database', + 'connection' => 'redis', 'queue' => 'queue1', ]); } - public function testFailedAndDontKeepJob() + public function testFailedAndDontKeepJob(): void { $handler = new RedisHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -171,17 +292,17 @@ public function testFailedAndDontKeepJob() $redis = self::getPrivateProperty($handler, 'redis'); $this->assertTrue($result); - $this->assertFalse($redis->hExists('queues:queue1::reserved', $queueJob->id)); + $this->assertFalse($redis->hExists('queues:queue1::reserved', (string) $queueJob->id)); $this->assertSame(0, $redis->zCard('queues:queue1:default')); $this->dontSeeInDatabase('queue_jobs_failed', [ 'id' => 2, - 'connection' => 'database', + 'connection' => 'redis', 'queue' => 'queue1', ]); } - public function testDoneAndKeepJob() + public function testDoneAndKeepJob(): void { $handler = new RedisHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -191,11 +312,11 @@ public function testDoneAndKeepJob() $redis = self::getPrivateProperty($handler, 'redis'); $this->assertTrue($result); - $this->assertFalse($redis->hExists('queues:queue1::reserved', $queueJob->id)); + $this->assertFalse($redis->hExists('queues:queue1::reserved', (string) $queueJob->id)); $this->assertSame(1, $redis->lLen('queues:queue1::done')); } - public function testDoneAndDontKeepJob() + public function testDoneAndDontKeepJob(): void { $handler = new RedisHandler($this->config); $queueJob = $handler->pop('queue1', ['default']); @@ -206,11 +327,11 @@ public function testDoneAndDontKeepJob() $result = $handler->done($queueJob, false); $this->assertTrue($result); - $this->assertFalse($redis->hExists('queues:queue1::reserved', $queueJob->id)); + $this->assertFalse($redis->hExists('queues:queue1::reserved', (string) $queueJob->id)); $this->assertSame(0, $redis->lLen('queues:queue1::done')); } - public function testClear() + public function testClear(): void { $handler = new RedisHandler($this->config); $result = $handler->clear('queue1'); @@ -224,7 +345,7 @@ public function testClear() $this->assertTrue($result); } - public function testClearAll() + public function testClearAll(): void { $handler = new RedisHandler($this->config); @@ -238,12 +359,12 @@ public function testClearAll() $this->assertTrue($result); } - public function testRetry() + public function testRetry(): void { $handler = new RedisHandler($this->config); $count = $handler->retry(1, 'queue1'); - $this->assertSame($count, 1); + $this->assertSame(1, $count); $redis = self::getPrivateProperty($handler, 'redis'); $this->assertSame(2, $redis->zCard('queues:queue1:default')); diff --git a/tests/_support/CLITestCase.php b/tests/_support/CLITestCase.php new file mode 100644 index 0000000..f0d405d --- /dev/null +++ b/tests/_support/CLITestCase.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support; + +use CodeIgniter\CLI\CLI; +use CodeIgniter\Test\ReflectionHelper; + +abstract class CLITestCase extends TestCase +{ + use ReflectionHelper; + + private array $lines = []; + + protected function parseOutput(string $output): string + { + $this->lines = []; + $output = $this->removeColorCodes($output); + $this->lines = explode("\n", $output); + + return $output; + } + + protected function getLine(int $line = 0): ?string + { + return $this->lines[$line] ?? null; + } + + protected function getLines(): string + { + return implode('', $this->lines); + } + + protected function removeColorCodes(string $output): string + { + $colors = $this->getPrivateProperty(CLI::class, 'foreground_colors'); + $colors = array_values(array_map(static fn ($color) => "\033[" . $color . 'm', $colors)); + $colors = array_merge(["\033[0m"], $colors); + + $output = str_replace($colors, '', trim($output)); + + if (is_windows()) { + $output = str_replace("\r\n", "\n", $output); + } + + return $output; + } +} diff --git a/tests/_support/Config/Queue.php b/tests/_support/Config/Queue.php index d71bee9..c027552 100644 --- a/tests/_support/Config/Queue.php +++ b/tests/_support/Config/Queue.php @@ -1,10 +1,22 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace Tests\Support\Config; use CodeIgniter\Queue\Config\Queue as BaseQueue; use CodeIgniter\Queue\Handlers\DatabaseHandler; use CodeIgniter\Queue\Handlers\PredisHandler; +use CodeIgniter\Queue\Handlers\RabbitMQHandler; use CodeIgniter\Queue\Handlers\RedisHandler; use Tests\Support\Jobs\Failure; use Tests\Support\Jobs\Success; @@ -23,14 +35,16 @@ class Queue extends BaseQueue 'database' => DatabaseHandler::class, 'redis' => RedisHandler::class, 'predis' => PredisHandler::class, + 'rabbitmq' => RabbitMQHandler::class, ]; /** * Database handler config. */ public array $database = [ - 'dbGroup' => 'default', - 'getShared' => true, + 'dbGroup' => 'default', + 'getShared' => true, + 'skipLocked' => true, ]; /** @@ -57,6 +71,17 @@ class Queue extends BaseQueue 'prefix' => '', ]; + /** + * RabbitMQ handler config. + */ + public array $rabbitmq = [ + 'host' => '127.0.0.1', + 'port' => 5672, + 'user' => 'guest', + 'password' => 'guest', + 'vhost' => '/', + ]; + /** * Whether to keep the DONE jobs in the queue. */ diff --git a/tests/_support/Config/Registrar.php b/tests/_support/Config/Registrar.php new file mode 100644 index 0000000..86a303e --- /dev/null +++ b/tests/_support/Config/Registrar.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Config; + +/** + * Class Registrar + * + * Provides a basic registrar class for testing BaseConfig registration functions. + */ +class Registrar +{ + /** + * DB config array for testing purposes. + * + * @var array|bool|int|string>> + */ + protected static array $dbConfig = [ + 'MySQLi' => [ + 'DSN' => '', + 'hostname' => '127.0.0.1', + 'username' => 'root', + 'password' => '', + 'database' => 'test', + 'DBDriver' => 'MySQLi', + 'DBPrefix' => 'db_', + 'pConnect' => false, + 'DBDebug' => true, + 'charset' => 'utf8mb4', + 'DBCollat' => 'utf8mb4_general_ci', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => 3306, + ], + 'Postgre' => [ + 'DSN' => '', + 'hostname' => 'localhost', + 'username' => 'postgres', + 'password' => 'postgres', + 'database' => 'test', + 'DBDriver' => 'Postgre', + 'DBPrefix' => 'db_', + 'pConnect' => false, + 'DBDebug' => true, + 'charset' => 'utf8', + 'DBCollat' => '', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => 5432, + ], + 'SQLite3' => [ + 'DSN' => '', + 'hostname' => 'localhost', + 'username' => '', + 'password' => '', + 'database' => 'database.db', + 'DBDriver' => 'SQLite3', + 'DBPrefix' => 'db_', + 'pConnect' => false, + 'DBDebug' => true, + 'charset' => 'utf8', + 'DBCollat' => '', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => 3306, + 'foreignKeys' => true, + ], + 'SQLSRV' => [ + 'DSN' => '', + 'hostname' => 'localhost', + 'username' => 'sa', + 'password' => '1Secure*Password1', + 'database' => 'test', + 'DBDriver' => 'SQLSRV', + 'DBPrefix' => 'db_', + 'pConnect' => false, + 'DBDebug' => true, + 'charset' => 'utf8', + 'DBCollat' => '', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => 1433, + ], + 'OCI8' => [ + 'DSN' => 'localhost:1521/XEPDB1', + 'hostname' => '', + 'username' => 'ORACLE', + 'password' => 'ORACLE', + 'database' => '', + 'DBDriver' => 'OCI8', + 'DBPrefix' => 'db_', + 'pConnect' => false, + 'DBDebug' => true, + 'charset' => 'AL32UTF8', + 'DBCollat' => '', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + ], + ]; + + /** + * Override database config + * + * @return array|bool|int|string> + */ + public static function Database(): array + { + $config = []; + + // Under GitHub Actions, we can set an ENV var named 'DB' + // so that we can test against multiple databases. + if (($group = getenv('DB')) && isset(self::$dbConfig[$group])) { + $config['tests'] = self::$dbConfig[$group]; + } + + return $config; + } +} diff --git a/tests/_support/Database/Seeds/TestDatabaseQueueSeeder.php b/tests/_support/Database/Seeds/TestDatabaseQueueSeeder.php index 1ca323b..1b04659 100644 --- a/tests/_support/Database/Seeds/TestDatabaseQueueSeeder.php +++ b/tests/_support/Database/Seeds/TestDatabaseQueueSeeder.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace Tests\Support\Database\Seeds; use CodeIgniter\Database\Seeder; diff --git a/tests/_support/Database/Seeds/TestRedisQueueSeeder.php b/tests/_support/Database/Seeds/TestRedisQueueSeeder.php index 76164b1..4fe4233 100644 --- a/tests/_support/Database/Seeds/TestRedisQueueSeeder.php +++ b/tests/_support/Database/Seeds/TestRedisQueueSeeder.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace Tests\Support\Database\Seeds; use CodeIgniter\Database\Seeder; @@ -40,7 +51,7 @@ public function run(): void 'attempts' => 0, 'available_at' => 1_697_269_864, ]); - $redis->hSet("queues:{$jobQueue->queue}::reserved", $jobQueue->id, json_encode($jobQueue)); + $redis->hSet("queues:{$jobQueue->queue}::reserved", (string) $jobQueue->id, json_encode($jobQueue)); $jobQueue = new QueueJob([ 'id' => '1234567890654321', diff --git a/tests/_support/Jobs/Failure.php b/tests/_support/Jobs/Failure.php index a7e66c0..7cad334 100644 --- a/tests/_support/Jobs/Failure.php +++ b/tests/_support/Jobs/Failure.php @@ -1,10 +1,21 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace Tests\Support\Jobs; -use Exception; use CodeIgniter\Queue\BaseJob; use CodeIgniter\Queue\Interfaces\JobInterface; +use Exception; class Failure extends BaseJob implements JobInterface { diff --git a/tests/_support/Jobs/Success.php b/tests/_support/Jobs/Success.php index 894cff5..e32f192 100644 --- a/tests/_support/Jobs/Success.php +++ b/tests/_support/Jobs/Success.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace Tests\Support\Jobs; use CodeIgniter\Queue\BaseJob; @@ -8,7 +19,7 @@ class Success extends BaseJob implements JobInterface { protected int $retryAfter = 6; - protected int $retries = 3; + protected int $tries = 3; public function process(): bool { diff --git a/tests/_support/TestCase.php b/tests/_support/TestCase.php index 48f2d40..0b956db 100644 --- a/tests/_support/TestCase.php +++ b/tests/_support/TestCase.php @@ -1,9 +1,22 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace Tests\Support; +use CodeIgniter\I18n\Time; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; +use Exception; abstract class TestCase extends CIUnitTestCase { @@ -17,4 +30,15 @@ protected function setUp(): void parent::setUp(); } + + /** + * @throws Exception + */ + protected function tearDown(): void + { + parent::tearDown(); + + // Reset the current time. + Time::setTestNow(); + } }