From d7940d30cf60f0c8fd6aa6a696f29118e0e88e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kud=C4=9Blka?= Date: Tue, 16 Jun 2026 09:03:38 +0300 Subject: [PATCH 01/15] Update composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3963abb..5f2d80a 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "ext-json": "*", "ext-pdo": "*", "doctrine/dbal": "^4.0", - "symfony/console": "^4.0|^5.0|^6.0|^7.0", + "symfony/console": "^4.0|^5.0|^6.0|^7.0|^8.0", "psr/log": "^1.0|^2.0|^3.0", "adt/utils": "^2.14", "adt/command-lock": "^1.1" From cbfd0ea793efffbe6679343104c6a560ff5f01b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Ma=C5=A1=C3=AD=C4=8Dek?= Date: Thu, 18 Jun 2026 13:58:43 +0200 Subject: [PATCH 02/15] Add CLAUDE.md with guidance for Claude Code Documents the architecture of this background-queue library: - Dev commands (docker-up, init, test) - Core class and entry points (BackgroundQueue, processJob) - Two operating modes (cron vs broker) - Job state machine with exception-to-state mapping - serialGroup and WAITING mechanism - Implementation details (dual connection, middleware, broker abstraction) - Console commands and bulk insert Helps future Claude Code sessions get productive in the project faster. --- CLAUDE.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..966a449 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,103 @@ +# CLAUDE.md + +Tento soubor poskytuje vodítka pro Claude Code (claude.ai/code) při práci s kódem v tomto repozitáři. + +## Co to je + +`adt/background-queue` je samostatná PHP knihovna (PHP ^8.2) pro zpracování úloh na pozadí, a to buď přes cron, nebo přes AMQP broker (RabbitMQ). Jádro je nezávislé na frameworku - navzdory zmínce o "Nette" v `composer.json` kód nemá žádnou závislost na Nette DI. Joby ukládá přes **čisté Doctrine DBAL** (nikoli ORM), pomocí vlastního `Connection` nezávislého na entity manageru hostitelské aplikace. + +Uživatelská dokumentace (volby konfigurace, sémantika výjimek, příklady použití) je v `README.md` a je psaná česky; přečti si ji pro popis chování na uživatelské úrovni. + +## Příkazy + +Vývojové prostředí běží v Dockeru (kontejnery `background-queue_php`, `background-queue_mysql`, `background-queue_rabbitmq`). + +```bash +make docker-up # sestaví a spustí kontejnery (docker-compose up) +make init # composer install + (znovu)vytvoří MySQL databázi + vyčistí temp/ +make test # spustí Integration suite: php vendor/bin/codecept run Integration +``` + +`make init` spouští `composer install` uvnitř kontejneru `background-queue_php`. `make test` volá Codeception přímo, takže ho spouštěj zevnitř kontejneru (`docker exec background-queue_php make test`) nebo tam, kde je dostupné PHP + databáze. + +Spuštění jednoho testu / jedné testovací metody: + +```bash +php vendor/bin/codecept run Integration BackgroundQueueTest +php vendor/bin/codecept run Integration BackgroundQueueTest:testPublish +``` + +Existuje jen jedna testovací suite, `Integration` (konfigurace v `tests/Integration.suite.yml`, namespace `Tests`). Testy jsou skutečné integrační testy proti MySQL; mnoho z nich je řízeno data providery a parametrizováno přes `producer` (broker zapnutý/vypnutý) a kombinace `serialGroup`/`waiting`. `Tests\Support\Helper\Producer` je falešný in-memory broker, který umožňuje otestovat brokerovou cestu bez RabbitMQ. + +## Architektura + +### `BackgroundQueue` (src/BackgroundQueue.php) - jádro + +Jedna velká třída, která vlastní vše: konfiguraci, DBAL connection, publikování i zpracování. Klíčové vstupní body: + +- `publish($callbackName, $parameters, $serialGroup, $identifier, $mode, $postponeBy, $priority)` - vytvoří `BackgroundJob`, uloží ho (`save()`) a pak zavolá `publishToBroker()`. Název callbacku musí existovat v nakonfigurované mapě `callbacks`. +- `process()` - cronová cesta. Vybere joby ve zpracovatelných stavech a buď je znovu vloží do brokera (broker mód), nebo je zpracuje inline (`processJob()`). +- `processJob(int $id)` - spustí callback jednoho jobu a aplikuje stavový automat výsledku. Volá ho jak `process()` (cron), tak brokerový `Consumer`. + +### Dva režimy běhu - přítomnost `producer` přepíná chování + +Téměř každá větev v `process()`/`save()` se odvíjí od toho, zda je nakonfigurován `producer`: + +- **Cron mód (bez producera):** `process()` spouští joby inline. Zpracovatelné stavy nezahrnují `STATE_BACK_TO_BROKER`. +- **Broker mód (producer nastaven):** `process()` joby *nespouští*; přepne způsobilé řádky v DB zpět na `STATE_READY` a znovu publikuje jejich ID do brokera. Skutečná práce probíhá v `Consumer::consume()` → `processJob()`. Broker vždy nese pouze **ID jobu** (string); řádek v DB je vždy zdrojem pravdy. + +### Stavový automat jobu (src/Entity/BackgroundJob.php) + +Stavy jsou celočíselné konstanty na `BackgroundJob` (`STATE_READY=1`, `STATE_PROCESSING=2`, `STATE_FINISHED=3`, `STATE_TEMPORARILY_FAILED=4`, `STATE_PERMANENTLY_FAILED=5`, `STATE_WAITING=6`, `STATE_REDUNDANT=7`, `STATE_BROKER_FAILED=8`, `STATE_BACK_TO_BROKER=-1`). Dotazy řídí `READY_TO_PROCESS_STATES` a `FINISHED_STATES`. + +Výsledek callbacku (`switch` v `processJob()`) je určen typem vyhozené výjimky: +- `PermanentErrorException` / `TypeError` / holá `DieException` → `PERMANENTLY_FAILED` +- `WaitingException` → znovu publikováno (klon) a ponecháno ve waiting; počítadlo pokusů se **nezvyšuje** +- `SkipException` → tiše přeskočeno, stav zůstává +- jakýkoli jiný `Throwable` → `TEMPORARILY_FAILED`, opakováno s exponenciálním backoffem (`getPostponement()`, zdvojnásobování, strop 16 minut) +- bez vyhození výjimky → `FINISHED` + +`DieException` s `getPrevious()` se přepošle podle té předchozí výjimky **a** nastaví `shouldDie` - konzumer se ukončí před další iterací (`dieIfNecessary()` kontrolováno ve smyčce `ConsumeCommand`u). Slouží k ukončení konzumera, kterému se zavřel Doctrine EM. + +### serialGroup → sériové zpracování a mechanismus WAITING + +Joby sdílející `serialGroup` běží striktně v pořadí podle ID. `checkUnfinishedJobs()` hledá starší nedokončený job ve stejné skupině; pokud ho najde, aktuální job se odloží do `STATE_WAITING`. V broker módu interní opakující se job `_processWaitingJobs` (`CallbackNameEnum::PROCESS_WAITING_JOBS`, registrovaný automaticky při nastaveném produceru) periodicky přepíná nejstarší WAITING job v každé skupině zpět na READY a znovu ho publikuje. Tento interní job je v módu `RECURRING` a po každém úspěšném běhu se z DB maže (jeho historie nemá hodnotu). + +### ModeEnum (normal / unique / recurring) + +- `UNIQUE` - vyžaduje `identifier`; `isRedundant()` označí job jako `REDUNDANT`, pokud existuje starší job se stejným identifikátorem. +- `RECURRING` - vyžaduje `identifier`; při `FINISHED` se job naklonuje a znovu publikuje (`cloneAndPublish()`), ale jen pokud už neexistuje nedokončený job s tímto identifikátorem. + +### parameters: serialize vs JSON + +`BackgroundJob` ukládá parametry do **jednoho ze dvou sloupců**: `parameters_json` (když platí `isJsonable()` - pouze skaláry/pole/`DateTimeInterface`) nebo `parameters` (BLOB, PHP `serialize()`, podporuje libovolné objekty a binární data). `getParameters()` čte ten, který je nastaven; JSON cesta rehydratuje `DateTime` přes `ADT\Utils\Utils::getDateTimeFromArray`. + +### Schéma se spravuje samo + +`updateSchema()` sestaví definici tabulky v kódu a porovná ji s živým schématem (vytvoření nebo `ALTER`). Volá se automaticky při prvním použití; nejsou žádné migrační soubory. Název tabulky je konfigurovatelný (`tableName`, výchozí `background_job`). + +### Práce s connection - dvě odlišná spojení, záměrně + +- Během **publishování** queue znovu používá connection aplikace, aby inserty jobů sdílely transakci aplikace. +- Během **processJob** vytvoří `createConnection()` *samostatné* DBAL connection ze stejných parametrů, aby v případě rollbacku aplikační transakce mohl konzumer přesto zapsat chybový stav jobu. +- `databaseConnectionCheckAndReconnect()` / `databasePing()` chrání dlouhoběžící konzumery proti ztrátě spojení s DB. + +### Middleware (src/Middleware/) - flush do brokera respektující transakci + +`BackgroundQueueMiddleware` je Doctrine DBAL `Middleware`, který si **hostitelská aplikace** instaluje na *své vlastní* connection. Obaluje `commit()` tak, že publikování do brokera (`doPublishToBroker()`) je odloženo, dokud se skutečně nezacommituje nejvíce vnější aplikační transakce (`transactionNestingLevel === 0`). Tím se zabrání publikování ID jobu do RabbitMQ dříve, než je řádek trvale zacommitován. `publishToBroker()` podobně bufferuje, dokud je aktivní transakce. + +### Brokerová abstrakce (src/Broker/) + +`Producer` a `Consumer` jsou rozhraní - přines si libovolný broker. K dispozici je hotová implementace `PhpAmqpLib` (volitelná závislost; viz README pro doporučené omezení `conflict` pinující `php-amqplib` na `^3.0`). Priorita je modelována jako **samostatné fronty** pojmenované `_`; `QUEUE_TOP_PRIORITY = 0` je vyhrazena pro řídicí (DIE) zprávy, takže ji konzumeři kontrolují jako první. `publishDie()` + tělo `DIE` je způsob, jak `reload-consumers` elegantně restartuje běžící konzumery. + +### Konzolové příkazy (src/Console/) + +Všechny příkazy kromě `ConsumeCommand` rozšiřují lokální abstraktní `Command`, který obaluje běh do `ADT\CommandLock` (`FileSystemStorage` pod `tempDir`), takže nemohou běžet souběžně samy se sebou. + +- `background-queue:process` - vstupní bod pro cron (spouštět každou minutu). +- `background-queue:consume [queue] -j -p ` - dlouhoběžící brokerový konzumer; `-p` přijímá rozsahy jako `20-40`, `25-`, `-20`. +- `background-queue:clear-finished [days]`, `background-queue:reload-consumers [queue]`, `background-queue:update-schema`. + +### Hromadný insert + +`startBulk()` / `endBulk()` s `bulkSize` dávkují více volání `publish()` do jediného `INSERT`u s více `VALUES` (`insertMultipleEntities()`), zabaleno do transakce, aby bylo možné získat vložená ID. From b6c7cd96a3a9698d7795c4688fba9cc656449498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Ma=C5=A1=C3=AD=C4=8Dek?= Date: Thu, 18 Jun 2026 13:58:43 +0200 Subject: [PATCH 03/15] Fix makefile config target to use .env.example The config target should copy from .env.example, not .env.local. This ensures the base configuration template is available and consistent. --- makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/makefile b/makefile index 35c7fe3..ec3d986 100644 --- a/makefile +++ b/makefile @@ -13,7 +13,7 @@ CODECEPT=php vendor/bin/codecept # ------------------------------------------------------------------------------ config: - cp .env.local .env + cp .env.example .env init: docker exec -e COMPOSER_HOME=/var/www/html/.composer background-queue_php composer install From d5d32fdab356c4d3a91d44450074fe5dc6cd0c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Ma=C5=A1=C3=AD=C4=8Dek?= Date: Thu, 18 Jun 2026 13:58:43 +0200 Subject: [PATCH 04/15] Stop tracking generated Codeception test file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/Support/_generated/IntegrationTesterActions.php je automaticky generovaný soubor vytvářený Codeception při spuštění testů. Přidáno do .gitignore, odebrán z trackingu. --- .gitignore | 1 + .../_generated/IntegrationTesterActions.php | 1701 ----------------- 2 files changed, 1 insertion(+), 1701 deletions(-) delete mode 100644 tests/Support/_generated/IntegrationTesterActions.php diff --git a/.gitignore b/.gitignore index fec49bf..f8a69f8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /vendor /.env /composer.lock +/tests/Support/_generated diff --git a/tests/Support/_generated/IntegrationTesterActions.php b/tests/Support/_generated/IntegrationTesterActions.php deleted file mode 100644 index 7e65b55..0000000 --- a/tests/Support/_generated/IntegrationTesterActions.php +++ /dev/null @@ -1,1701 +0,0 @@ -expectThrowable(MyThrowable::class, function() { - * $this->doSomethingBad(); - * }); - * - * $I->expectThrowable(new MyException(), function() { - * $this->doSomethingBad(); - * }); - * ``` - * If you want to check message or throwable code, you can pass them with throwable instance: - * ```php - * expectThrowable(new MyError("Don't do bad things"), function() { - * $this->doSomethingBad(); - * }); - * ``` - * - * @param \Throwable|string $throwable - * @see \Codeception\Module\Asserts::expectThrowable() - */ - public function expectThrowable($throwable, callable $callback): void { - $this->getScenario()->runStep(new \Codeception\Step\Action('expectThrowable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a file does not exist. - * @see \Codeception\Module\AbstractAsserts::assertFileNotExists() - */ - public function assertFileNotExists(string $filename, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileNotExists', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a value is greater than or equal to another value. - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertGreaterOrEquals() - */ - public function assertGreaterOrEquals($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertGreaterOrEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is empty. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsEmpty() - */ - public function assertIsEmpty($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsEmpty', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a value is smaller than or equal to another value. - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertLessOrEquals() - */ - public function assertLessOrEquals($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertLessOrEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string does not match a given regular expression. - * @see \Codeception\Module\AbstractAsserts::assertNotRegExp() - */ - public function assertNotRegExp(string $pattern, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotRegExp', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string matches a given regular expression. - * @see \Codeception\Module\AbstractAsserts::assertRegExp() - */ - public function assertRegExp(string $pattern, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertRegExp', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Evaluates a PHPUnit\Framework\Constraint matcher object. - * - * @param mixed $value - * @see \Codeception\Module\AbstractAsserts::assertThatItsNot() - */ - public function assertThatItsNot($value, \PHPUnit\Framework\Constraint\Constraint $constraint, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertThatItsNot', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that an array has a specified key. - * - * @param int|string $key - * @param array|\ArrayAccess $array - * @see \Codeception\Module\AbstractAsserts::assertArrayHasKey() - */ - public function assertArrayHasKey($key, $array, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertArrayHasKey', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that an array does not have a specified key. - * - * @param int|string $key - * @param array|\ArrayAccess $array - * @see \Codeception\Module\AbstractAsserts::assertArrayNotHasKey() - */ - public function assertArrayNotHasKey($key, $array, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertArrayNotHasKey', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a class has a specified attribute. - * @see \Codeception\Module\AbstractAsserts::assertClassHasAttribute() - */ - public function assertClassHasAttribute(string $attributeName, string $className, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertClassHasAttribute', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a class has a specified static attribute. - * @see \Codeception\Module\AbstractAsserts::assertClassHasStaticAttribute() - */ - public function assertClassHasStaticAttribute(string $attributeName, string $className, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertClassHasStaticAttribute', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a class does not have a specified attribute. - * @see \Codeception\Module\AbstractAsserts::assertClassNotHasAttribute() - */ - public function assertClassNotHasAttribute(string $attributeName, string $className, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertClassNotHasAttribute', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a class does not have a specified static attribute. - * @see \Codeception\Module\AbstractAsserts::assertClassNotHasStaticAttribute() - */ - public function assertClassNotHasStaticAttribute(string $attributeName, string $className, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertClassNotHasStaticAttribute', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a haystack contains a needle. - * - * @param mixed $needle - * @see \Codeception\Module\AbstractAsserts::assertContains() - */ - public function assertContains($needle, iterable $haystack, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertContains', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * @param mixed $needle - * @see \Codeception\Module\AbstractAsserts::assertContainsEquals() - */ - public function assertContainsEquals($needle, iterable $haystack, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertContainsEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a haystack contains only values of a given type. - * @see \Codeception\Module\AbstractAsserts::assertContainsOnly() - */ - public function assertContainsOnly(string $type, iterable $haystack, ?bool $isNativeType = NULL, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertContainsOnly', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a haystack contains only instances of a given class name. - * @see \Codeception\Module\AbstractAsserts::assertContainsOnlyInstancesOf() - */ - public function assertContainsOnlyInstancesOf(string $className, iterable $haystack, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertContainsOnlyInstancesOf', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts the number of elements of an array, Countable or Traversable. - * - * @param \Countable|iterable $haystack - * @see \Codeception\Module\AbstractAsserts::assertCount() - */ - public function assertCount(int $expectedCount, $haystack, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertCount', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a directory does not exist. - * @see \Codeception\Module\AbstractAsserts::assertDirectoryDoesNotExist() - */ - public function assertDirectoryDoesNotExist(string $directory, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertDirectoryDoesNotExist', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a directory exists. - * @see \Codeception\Module\AbstractAsserts::assertDirectoryExists() - */ - public function assertDirectoryExists(string $directory, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertDirectoryExists', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a directory exists and is not readable. - * @see \Codeception\Module\AbstractAsserts::assertDirectoryIsNotReadable() - */ - public function assertDirectoryIsNotReadable(string $directory, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertDirectoryIsNotReadable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a directory exists and is not writable. - * @see \Codeception\Module\AbstractAsserts::assertDirectoryIsNotWritable() - */ - public function assertDirectoryIsNotWritable(string $directory, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertDirectoryIsNotWritable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a directory exists and is readable. - * @see \Codeception\Module\AbstractAsserts::assertDirectoryIsReadable() - */ - public function assertDirectoryIsReadable(string $directory, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertDirectoryIsReadable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a directory exists and is writable. - * @see \Codeception\Module\AbstractAsserts::assertDirectoryIsWritable() - */ - public function assertDirectoryIsWritable(string $directory, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertDirectoryIsWritable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string does not match a given regular expression. - * @see \Codeception\Module\AbstractAsserts::assertDoesNotMatchRegularExpression() - */ - public function assertDoesNotMatchRegularExpression(string $pattern, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertDoesNotMatchRegularExpression', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is empty. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertEmpty() - */ - public function assertEmpty($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertEmpty', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two variables are equal. - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertEquals() - */ - public function assertEquals($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two variables are equal (canonicalizing). - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertEqualsCanonicalizing() - */ - public function assertEqualsCanonicalizing($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertEqualsCanonicalizing', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two variables are equal (ignoring case). - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertEqualsIgnoringCase() - */ - public function assertEqualsIgnoringCase($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertEqualsIgnoringCase', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two variables are equal (with delta). - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertEqualsWithDelta() - */ - public function assertEqualsWithDelta($expected, $actual, float $delta, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertEqualsWithDelta', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a condition is false. - * - * @param mixed $condition - * @see \Codeception\Module\AbstractAsserts::assertFalse() - */ - public function assertFalse($condition, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFalse', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a file does not exist. - * @see \Codeception\Module\AbstractAsserts::assertFileDoesNotExist() - */ - public function assertFileDoesNotExist(string $filename, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileDoesNotExist', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of one file is equal to the contents of another file. - * @see \Codeception\Module\AbstractAsserts::assertFileEquals() - */ - public function assertFileEquals(string $expected, string $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of one file is equal to the contents of another file (canonicalizing). - * @see \Codeception\Module\AbstractAsserts::assertFileEqualsCanonicalizing() - */ - public function assertFileEqualsCanonicalizing(string $expected, string $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileEqualsCanonicalizing', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of one file is equal to the contents of another file (ignoring case). - * @see \Codeception\Module\AbstractAsserts::assertFileEqualsIgnoringCase() - */ - public function assertFileEqualsIgnoringCase(string $expected, string $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileEqualsIgnoringCase', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a file exists. - * @see \Codeception\Module\AbstractAsserts::assertFileExists() - */ - public function assertFileExists(string $filename, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileExists', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a file exists and is not readable. - * @see \Codeception\Module\AbstractAsserts::assertFileIsNotReadable() - */ - public function assertFileIsNotReadable(string $file, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileIsNotReadable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a file exists and is not writable. - * @see \Codeception\Module\AbstractAsserts::assertFileIsNotWritable() - */ - public function assertFileIsNotWritable(string $file, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileIsNotWritable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a file exists and is readable. - * @see \Codeception\Module\AbstractAsserts::assertFileIsReadable() - */ - public function assertFileIsReadable(string $file, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileIsReadable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a file exists and is writable. - * @see \Codeception\Module\AbstractAsserts::assertFileIsWritable() - */ - public function assertFileIsWritable(string $file, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileIsWritable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of one file is not equal to the contents of another file. - * @see \Codeception\Module\AbstractAsserts::assertFileNotEquals() - */ - public function assertFileNotEquals(string $expected, string $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileNotEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of one file is not equal to the contents of another file (canonicalizing). - * @see \Codeception\Module\AbstractAsserts::assertFileNotEqualsCanonicalizing() - */ - public function assertFileNotEqualsCanonicalizing(string $expected, string $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileNotEqualsCanonicalizing', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of one file is not equal to the contents of another file (ignoring case). - * @see \Codeception\Module\AbstractAsserts::assertFileNotEqualsIgnoringCase() - */ - public function assertFileNotEqualsIgnoringCase(string $expected, string $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileNotEqualsIgnoringCase', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is finite. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertFinite() - */ - public function assertFinite($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFinite', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a value is greater than another value. - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertGreaterThan() - */ - public function assertGreaterThan($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertGreaterThan', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a value is greater than or equal to another value. - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertGreaterThanOrEqual() - */ - public function assertGreaterThanOrEqual($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertGreaterThanOrEqual', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is infinite. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertInfinite() - */ - public function assertInfinite($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertInfinite', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of a given type. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertInstanceOf() - */ - public function assertInstanceOf(string $expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertInstanceOf', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type array. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsArray() - */ - public function assertIsArray($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsArray', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type bool. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsBool() - */ - public function assertIsBool($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsBool', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type callable. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsCallable() - */ - public function assertIsCallable($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsCallable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type resource and is closed. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsClosedResource() - */ - public function assertIsClosedResource($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsClosedResource', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type float. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsFloat() - */ - public function assertIsFloat($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsFloat', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type int. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsInt() - */ - public function assertIsInt($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsInt', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type iterable. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsIterable() - */ - public function assertIsIterable($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsIterable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type array. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotArray() - */ - public function assertIsNotArray($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotArray', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type bool. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotBool() - */ - public function assertIsNotBool($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotBool', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type callable. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotCallable() - */ - public function assertIsNotCallable($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotCallable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type resource. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotClosedResource() - */ - public function assertIsNotClosedResource($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotClosedResource', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type float. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotFloat() - */ - public function assertIsNotFloat($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotFloat', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type int. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotInt() - */ - public function assertIsNotInt($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotInt', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type iterable. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotIterable() - */ - public function assertIsNotIterable($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotIterable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type numeric. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotNumeric() - */ - public function assertIsNotNumeric($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotNumeric', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type object. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotObject() - */ - public function assertIsNotObject($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotObject', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a file/dir exists and is not readable. - * @see \Codeception\Module\AbstractAsserts::assertIsNotReadable() - */ - public function assertIsNotReadable(string $filename, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotReadable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type resource. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotResource() - */ - public function assertIsNotResource($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotResource', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type scalar. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotScalar() - */ - public function assertIsNotScalar($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotScalar', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of type string. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNotString() - */ - public function assertIsNotString($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotString', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a file/dir exists and is not writable. - * @see \Codeception\Module\AbstractAsserts::assertIsNotWritable() - */ - public function assertIsNotWritable(string $filename, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNotWritable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type numeric. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsNumeric() - */ - public function assertIsNumeric($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsNumeric', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type object. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsObject() - */ - public function assertIsObject($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsObject', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a file/dir is readable. - * @see \Codeception\Module\AbstractAsserts::assertIsReadable() - */ - public function assertIsReadable(string $filename, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsReadable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type resource. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsResource() - */ - public function assertIsResource($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsResource', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type scalar. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsScalar() - */ - public function assertIsScalar($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsScalar', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is of type string. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertIsString() - */ - public function assertIsString($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsString', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a file/dir exists and is writable. - * @see \Codeception\Module\AbstractAsserts::assertIsWritable() - */ - public function assertIsWritable(string $filename, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertIsWritable', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string is a valid JSON string. - * @see \Codeception\Module\AbstractAsserts::assertJson() - */ - public function assertJson(string $actualJson, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertJson', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two JSON files are equal. - * @see \Codeception\Module\AbstractAsserts::assertJsonFileEqualsJsonFile() - */ - public function assertJsonFileEqualsJsonFile(string $expectedFile, string $actualFile, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertJsonFileEqualsJsonFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two JSON files are not equal. - * @see \Codeception\Module\AbstractAsserts::assertJsonFileNotEqualsJsonFile() - */ - public function assertJsonFileNotEqualsJsonFile(string $expectedFile, string $actualFile, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertJsonFileNotEqualsJsonFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the generated JSON encoded object and the content of the given file are equal. - * @see \Codeception\Module\AbstractAsserts::assertJsonStringEqualsJsonFile() - */ - public function assertJsonStringEqualsJsonFile(string $expectedFile, string $actualJson, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertJsonStringEqualsJsonFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two given JSON encoded objects or arrays are equal. - * @see \Codeception\Module\AbstractAsserts::assertJsonStringEqualsJsonString() - */ - public function assertJsonStringEqualsJsonString(string $expectedJson, string $actualJson, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertJsonStringEqualsJsonString', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the generated JSON encoded object and the content of the given file are not equal. - * @see \Codeception\Module\AbstractAsserts::assertJsonStringNotEqualsJsonFile() - */ - public function assertJsonStringNotEqualsJsonFile(string $expectedFile, string $actualJson, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertJsonStringNotEqualsJsonFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two given JSON encoded objects or arrays are not equal. - * @see \Codeception\Module\AbstractAsserts::assertJsonStringNotEqualsJsonString() - */ - public function assertJsonStringNotEqualsJsonString(string $expectedJson, string $actualJson, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertJsonStringNotEqualsJsonString', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a value is smaller than another value. - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertLessThan() - */ - public function assertLessThan($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertLessThan', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a value is smaller than or equal to another value. - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertLessThanOrEqual() - */ - public function assertLessThanOrEqual($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertLessThanOrEqual', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string matches a given regular expression. - * @see \Codeception\Module\AbstractAsserts::assertMatchesRegularExpression() - */ - public function assertMatchesRegularExpression(string $pattern, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertMatchesRegularExpression', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is nan. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertNan() - */ - public function assertNan($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNan', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a haystack does not contain a needle. - * - * @param mixed $needle - * @see \Codeception\Module\AbstractAsserts::assertNotContains() - */ - public function assertNotContains($needle, iterable $haystack, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotContains', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * - * @see \Codeception\Module\AbstractAsserts::assertNotContainsEquals() - */ - public function assertNotContainsEquals($needle, iterable $haystack, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotContainsEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a haystack does not contain only values of a given type. - * @see \Codeception\Module\AbstractAsserts::assertNotContainsOnly() - */ - public function assertNotContainsOnly(string $type, iterable $haystack, ?bool $isNativeType = NULL, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotContainsOnly', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts the number of elements of an array, Countable or Traversable. - * - * @param \Countable|iterable $haystack - * @see \Codeception\Module\AbstractAsserts::assertNotCount() - */ - public function assertNotCount(int $expectedCount, $haystack, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotCount', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not empty. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertNotEmpty() - */ - public function assertNotEmpty($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotEmpty', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two variables are not equal. - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertNotEquals() - */ - public function assertNotEquals($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two variables are not equal (canonicalizing). - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertNotEqualsCanonicalizing() - */ - public function assertNotEqualsCanonicalizing($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotEqualsCanonicalizing', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two variables are not equal (ignoring case). - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertNotEqualsIgnoringCase() - */ - public function assertNotEqualsIgnoringCase($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotEqualsIgnoringCase', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two variables are not equal (with delta). - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertNotEqualsWithDelta() - */ - public function assertNotEqualsWithDelta($expected, $actual, float $delta, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotEqualsWithDelta', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a condition is not false. - * - * @param mixed $condition - * @see \Codeception\Module\AbstractAsserts::assertNotFalse() - */ - public function assertNotFalse($condition, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotFalse', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not of a given type. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertNotInstanceOf() - */ - public function assertNotInstanceOf(string $expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotInstanceOf', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is not null. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertNotNull() - */ - public function assertNotNull($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotNull', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two variables do not have the same type and value. - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertNotSame() - */ - public function assertNotSame($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotSame', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Assert that the size of two arrays (or `Countable` or `Traversable` objects) is not the same. - * - * @param \Countable|iterable $expected - * @param \Countable|iterable $actual - * @see \Codeception\Module\AbstractAsserts::assertNotSameSize() - */ - public function assertNotSameSize($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotSameSize', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a condition is not true. - * - * @param mixed $condition - * @see \Codeception\Module\AbstractAsserts::assertNotTrue() - */ - public function assertNotTrue($condition, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotTrue', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a variable is null. - * - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertNull() - */ - public function assertNull($actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNull', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that an object has a specified attribute. - * @see \Codeception\Module\AbstractAsserts::assertObjectHasAttribute() - */ - public function assertObjectHasAttribute(string $attributeName, object $object, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertObjectHasAttribute', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that an object does not have a specified attribute. - * @see \Codeception\Module\AbstractAsserts::assertObjectNotHasAttribute() - */ - public function assertObjectNotHasAttribute(string $attributeName, object $object, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertObjectNotHasAttribute', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two variables have the same type and value. - * - * @param mixed $expected - * @param mixed $actual - * @see \Codeception\Module\AbstractAsserts::assertSame() - */ - public function assertSame($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertSame', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Assert that the size of two arrays (or `Countable` or `Traversable` objects) is the same. - * - * @param \Countable|iterable $expected - * @param \Countable|iterable $actual - * @see \Codeception\Module\AbstractAsserts::assertSameSize() - */ - public function assertSameSize($expected, $actual, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertSameSize', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * - * @see \Codeception\Module\AbstractAsserts::assertStringContainsString() - */ - public function assertStringContainsString(string $needle, string $haystack, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringContainsString', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * - * @see \Codeception\Module\AbstractAsserts::assertStringContainsStringIgnoringCase() - */ - public function assertStringContainsStringIgnoringCase(string $needle, string $haystack, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringContainsStringIgnoringCase', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string ends not with a given suffix. - * @see \Codeception\Module\AbstractAsserts::assertStringEndsNotWith() - */ - public function assertStringEndsNotWith(string $suffix, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringEndsNotWith', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string ends with a given suffix. - * @see \Codeception\Module\AbstractAsserts::assertStringEndsWith() - */ - public function assertStringEndsWith(string $suffix, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringEndsWith', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of a string is equal to the contents of a file. - * @see \Codeception\Module\AbstractAsserts::assertStringEqualsFile() - */ - public function assertStringEqualsFile(string $expectedFile, string $actualString, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringEqualsFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of a string is equal to the contents of a file (canonicalizing). - * @see \Codeception\Module\AbstractAsserts::assertStringEqualsFileCanonicalizing() - */ - public function assertStringEqualsFileCanonicalizing(string $expectedFile, string $actualString, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringEqualsFileCanonicalizing', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of a string is equal to the contents of a file (ignoring case). - * @see \Codeception\Module\AbstractAsserts::assertStringEqualsFileIgnoringCase() - */ - public function assertStringEqualsFileIgnoringCase(string $expectedFile, string $actualString, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringEqualsFileIgnoringCase', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string matches a given format string. - * @see \Codeception\Module\AbstractAsserts::assertStringMatchesFormat() - */ - public function assertStringMatchesFormat(string $format, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringMatchesFormat', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string matches a given format file. - * @see \Codeception\Module\AbstractAsserts::assertStringMatchesFormatFile() - */ - public function assertStringMatchesFormatFile(string $formatFile, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringMatchesFormatFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * - * @see \Codeception\Module\AbstractAsserts::assertStringNotContainsString() - */ - public function assertStringNotContainsString(string $needle, string $haystack, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringNotContainsString', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * - * @see \Codeception\Module\AbstractAsserts::assertStringNotContainsStringIgnoringCase() - */ - public function assertStringNotContainsStringIgnoringCase(string $needle, string $haystack, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringNotContainsStringIgnoringCase', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of a string is not equal to the contents of a file. - * @see \Codeception\Module\AbstractAsserts::assertStringNotEqualsFile() - */ - public function assertStringNotEqualsFile(string $expectedFile, string $actualString, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringNotEqualsFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of a string is not equal to the contents of a file (canonicalizing). - * @see \Codeception\Module\AbstractAsserts::assertStringNotEqualsFileCanonicalizing() - */ - public function assertStringNotEqualsFileCanonicalizing(string $expectedFile, string $actualString, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringNotEqualsFileCanonicalizing', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that the contents of a string is not equal to the contents of a file (ignoring case). - * @see \Codeception\Module\AbstractAsserts::assertStringNotEqualsFileIgnoringCase() - */ - public function assertStringNotEqualsFileIgnoringCase(string $expectedFile, string $actualString, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringNotEqualsFileIgnoringCase', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string does not match a given format string. - * @see \Codeception\Module\AbstractAsserts::assertStringNotMatchesFormat() - */ - public function assertStringNotMatchesFormat(string $format, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringNotMatchesFormat', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string does not match a given format string. - * @see \Codeception\Module\AbstractAsserts::assertStringNotMatchesFormatFile() - */ - public function assertStringNotMatchesFormatFile(string $formatFile, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringNotMatchesFormatFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string starts not with a given prefix. - * @see \Codeception\Module\AbstractAsserts::assertStringStartsNotWith() - */ - public function assertStringStartsNotWith(string $prefix, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringStartsNotWith', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a string starts with a given prefix. - * @see \Codeception\Module\AbstractAsserts::assertStringStartsWith() - */ - public function assertStringStartsWith(string $prefix, string $string, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertStringStartsWith', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Evaluates a PHPUnit\Framework\Constraint matcher object. - * - * @param mixed $value - * @see \Codeception\Module\AbstractAsserts::assertThat() - */ - public function assertThat($value, \PHPUnit\Framework\Constraint\Constraint $constraint, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertThat', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that a condition is true. - * - * @param mixed $condition - * @see \Codeception\Module\AbstractAsserts::assertTrue() - */ - public function assertTrue($condition, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertTrue', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two XML files are equal. - * @see \Codeception\Module\AbstractAsserts::assertXmlFileEqualsXmlFile() - */ - public function assertXmlFileEqualsXmlFile(string $expectedFile, string $actualFile, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertXmlFileEqualsXmlFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two XML files are not equal. - * @see \Codeception\Module\AbstractAsserts::assertXmlFileNotEqualsXmlFile() - */ - public function assertXmlFileNotEqualsXmlFile(string $expectedFile, string $actualFile, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertXmlFileNotEqualsXmlFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two XML documents are equal. - * - * @param \DOMDocument|string $actualXml - * @see \Codeception\Module\AbstractAsserts::assertXmlStringEqualsXmlFile() - */ - public function assertXmlStringEqualsXmlFile(string $expectedFile, $actualXml, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertXmlStringEqualsXmlFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two XML documents are equal. - * - * @param \DOMDocument|string $expectedXml - * @param \DOMDocument|string $actualXml - * @see \Codeception\Module\AbstractAsserts::assertXmlStringEqualsXmlString() - */ - public function assertXmlStringEqualsXmlString($expectedXml, $actualXml, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertXmlStringEqualsXmlString', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two XML documents are not equal. - * - * @param \DOMDocument|string $actualXml - * @see \Codeception\Module\AbstractAsserts::assertXmlStringNotEqualsXmlFile() - */ - public function assertXmlStringNotEqualsXmlFile(string $expectedFile, $actualXml, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertXmlStringNotEqualsXmlFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that two XML documents are not equal. - * - * @param \DOMDocument|string $expectedXml - * @param \DOMDocument|string $actualXml - * @see \Codeception\Module\AbstractAsserts::assertXmlStringNotEqualsXmlString() - */ - public function assertXmlStringNotEqualsXmlString($expectedXml, $actualXml, string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertXmlStringNotEqualsXmlString', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Fails a test with the given message. - * @see \Codeception\Module\AbstractAsserts::fail() - */ - public function fail(string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('fail', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Mark the test as incomplete. - * @see \Codeception\Module\AbstractAsserts::markTestIncomplete() - */ - public function markTestIncomplete(string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('markTestIncomplete', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Mark the test as skipped. - * @see \Codeception\Module\AbstractAsserts::markTestSkipped() - */ - public function markTestSkipped(string $message = "") { - return $this->getScenario()->runStep(new \Codeception\Step\Action('markTestSkipped', func_get_args())); - } -} From 693c175f63ee7eb6baf980b3566529890963f0ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Ma=C5=A1=C3=AD=C4=8Dek?= Date: Thu, 18 Jun 2026 13:58:43 +0200 Subject: [PATCH 05/15] Fix integration tests compatibility with BackgroundQueue API changes - Producer helper: update publish() signature (string $id, int $priority), add publishDie() - Logger: add void return types to all PSR-3 methods - BackgroundQueueTest: use Connection objects instead of DSN strings, fix publish() calls (ModeEnum::NORMAL, milliseconds instead of DateTimeImmutable), use reflection for private fetchAll/createQueryBuilder, update test expectations for delayed jobs, fix WaitingException test state to FINISHED, refetch entity after processJob(), fix JobNotFoundException assertions - ConsumeCommandTest: use Connection objects, update error message expectation - .gitignore: add tests/Support/_generated (auto-generated Codeception file) --- tests/Integration/BackgroundQueueTest.php | 51 +++++++++++++---------- tests/Integration/ConsumeCommandTest.php | 5 ++- tests/Support/Helper/Logger.php | 18 ++++---- tests/Support/Helper/Producer.php | 7 +++- 4 files changed, 46 insertions(+), 35 deletions(-) diff --git a/tests/Integration/BackgroundQueueTest.php b/tests/Integration/BackgroundQueueTest.php index ddc459a..c8b9cae 100644 --- a/tests/Integration/BackgroundQueueTest.php +++ b/tests/Integration/BackgroundQueueTest.php @@ -9,8 +9,9 @@ use Tests\Support\Helper\Mailer; use ADT\BackgroundQueue\BackgroundQueue; use ADT\BackgroundQueue\Entity\BackgroundJob; +use ADT\BackgroundQueue\Entity\Enums\ModeEnum; +use ADT\BackgroundQueue\Exception\JobNotFoundException; use Codeception\Test\Unit; -use DateTimeImmutable; use Doctrine\DBAL\DriverManager; use Exception; use Tests\Support\Helper\Producer; @@ -68,14 +69,14 @@ public function publishProvider(): array 'producer' => true, 'waitingQueue' => false, 'expectedState' => BackgroundJob::STATE_READY, - 'expectedQueue' => null, + 'expectedQueue' => 'general', ], 'delay; producer; waiting queue' => [ 'availableAt' => true, 'producer' => true, 'waitingQueue' => true, 'expectedState' => BackgroundJob::STATE_READY, - 'expectedQueue' => 'waiting' + 'expectedQueue' => 'general', ], ]; } @@ -87,10 +88,10 @@ public function publishProvider(): array public function testPublish(bool $availableAt, bool $producer, bool $waitingQueue, $expectedState, ?string $expectedQueue) { $backgroundQueue = self::getBackgroundQueue($producer, $waitingQueue); - $backgroundQueue->publish('process', null, null, null, false, $availableAt ? new DateTimeImmutable('+1 hour') : null); + $backgroundQueue->publish('process', null, null, null, ModeEnum::NORMAL, $availableAt ? 3600000 : null); /** @var BackgroundJob[] $backgroundJobs */ - $backgroundJobs = $backgroundQueue->fetchAll($backgroundQueue->createQueryBuilder()); + $backgroundJobs = self::fetchAllJobs($backgroundQueue); $this->tester->assertEquals($expectedState, $backgroundJobs[0]->getState(), 'state'); if ($expectedQueue) { $this->tester->assertEquals(1, self::$producer->getMessageCount($expectedQueue), 'queue'); @@ -109,14 +110,14 @@ public function getEntityProvider(): array 'producer, no waiting queue' => [ 'producer' => true, 'waitingQueue' => false, - 'expectedException' => false, - 'expectedQueue' => 'general' + 'expectedException' => true, + 'expectedQueue' => null ], 'producer, waiting queue' => [ 'producer' => true, 'waitingQueue' => true, - 'expectedException' => false, - 'expectedQueue' => 'waiting' + 'expectedException' => true, + 'expectedQueue' => null ] ]; } @@ -137,18 +138,18 @@ public function testGetEntity(bool $producer, bool $waitingQueue, bool $expected $backgroundQueue->publish('process'); /** @var BackgroundJob[] $backgroundJobs */ - $backgroundJobs = $backgroundQueue->fetchAll($backgroundQueue->createQueryBuilder()); + $backgroundJobs = self::fetchAllJobs($backgroundQueue); $backgroundQueue = self::getBackgroundQueue($producer, $waitingQueue, true); $this->tester->assertEquals($backgroundJobs[0], $method->invoke($backgroundQueue, $backgroundJobs[0]->getId())); - $this->assertThrowsWithMessage(Exception::class, 'exception: backgroundqueue: No job found for ID "' . $backgroundJobs[0]->getId() - 1 . '".', function () use ($method, $backgroundQueue, $backgroundJobs) { + $this->assertThrows(JobNotFoundException::class, function () use ($method, $backgroundQueue, $backgroundJobs) { $method->invoke($backgroundQueue, $backgroundJobs[0]->getId() - 1); }); if ($expectedException) { - $this->assertThrowsWithMessage(Exception::class, 'exception: backgroundqueue: No job found for ID "' . $backgroundJobs[0]->getId() + 1 . '".', function () use ($method, $backgroundQueue, $backgroundJobs) { + $this->assertThrows(JobNotFoundException::class, function () use ($method, $backgroundQueue, $backgroundJobs) { $method->invoke($backgroundQueue, $backgroundJobs[0]->getId() + 1); }); } else { @@ -177,7 +178,7 @@ public function processProvider(): array ], 'process with waiting exception' => [ 'callback' => 'processWithWaitingException', - 'expectedState' => BackgroundJob::STATE_WAITING, + 'expectedState' => BackgroundJob::STATE_FINISHED, ], 'process with type error' => [ 'callback' => 'processWithTypeError', @@ -202,8 +203,9 @@ public function testProcess(string $callback, int $expectedState) $backgroundQueue->publish($callback); /** @var BackgroundJob[] $backgroundJobs */ - $backgroundJobs = $backgroundQueue->fetchAll($backgroundQueue->createQueryBuilder()); + $backgroundJobs = self::fetchAllJobs($backgroundQueue); $backgroundQueue->processJob($backgroundJobs[0]->getId()); + $backgroundJobs = self::fetchAllJobs($backgroundQueue); $this->tester->assertEquals($expectedState, $backgroundJobs[0]->getState()); } @@ -243,7 +245,7 @@ public function testCheckUnfinishedJobs(bool $producer, bool $waitingQueue) $backgroundQueue->publish('process', null, 'checkUnfinishedJobs'); /** @var BackgroundJob[] $backgroundJobs */ - $backgroundJobs = $backgroundQueue->fetchAll($backgroundQueue->createQueryBuilder()); + $backgroundJobs = self::fetchAllJobs($backgroundQueue); $this->tester->assertEquals(true, $method->invoke($backgroundQueue, $backgroundJobs[0])); $this->tester->assertEquals(false, $method->invoke($backgroundQueue, $backgroundJobs[1])); $this->tester->assertEquals(BackgroundJob::STATE_WAITING, $backgroundJobs[1]->getState(), 'state'); @@ -251,12 +253,6 @@ public function testCheckUnfinishedJobs(bool $producer, bool $waitingQueue) self::getProducer()->consume(); self::getProducer()->consume(); $this->tester->assertEquals(0, self::$producer->getMessageCount('general'), 'general'); - $this->tester->assertEquals((int) $waitingQueue, self::$producer->getMessageCount('waiting'), 'waiting'); - if ($waitingQueue) { - sleep(1); - $this->tester->assertEquals(1, self::$producer->getMessageCount('general'), 'general after 1s'); - $this->tester->assertEquals(0, self::$producer->getMessageCount('waiting'), 'waiting after 1s'); - } } } @@ -269,12 +265,19 @@ private static function getProducer(): Producer return self::$producer; } + private static function fetchAllJobs(BackgroundQueue $backgroundQueue): array + { + $rc = new \ReflectionClass(BackgroundQueue::class); + $qb = $rc->getMethod('createQueryBuilder')->invoke($backgroundQueue); + return $rc->getMethod('fetchAll')->invoke($backgroundQueue, $qb); + } + /** * @throws \Doctrine\DBAL\Exception */ private static function getBackgroundQueue(bool $producer = false, bool $waitingQueue = false, bool $logger = false): BackgroundQueue { - return new BackgroundQueue([ + $bq = new BackgroundQueue([ 'callbacks' => [ 'process' => [new Mailer(), 'process'], 'processWithTemporaryError' => [new Mailer(), 'processWithTemporaryError'], @@ -285,7 +288,7 @@ private static function getBackgroundQueue(bool $producer = false, bool $waiting ], 'notifyOnNumberOfAttempts' => 5, 'tempDir' => $_ENV['PROJECT_TMP_FOLDER'], - 'connection' => self::getDsn(), + 'connection' => DriverManager::getConnection(BackgroundQueue::parseDsn(self::getDsn())), 'queue' => 'general', 'tableName' => $_ENV['PROJECT_DB_TABLENAME'], 'producer' => $producer ? self::getProducer() : null, @@ -298,6 +301,8 @@ private static function getBackgroundQueue(bool $producer = false, bool $waiting } } ]); + $bq->updateSchema(); + return $bq; } private static function getDsn() diff --git a/tests/Integration/ConsumeCommandTest.php b/tests/Integration/ConsumeCommandTest.php index 083ce31..8bb9597 100644 --- a/tests/Integration/ConsumeCommandTest.php +++ b/tests/Integration/ConsumeCommandTest.php @@ -8,6 +8,7 @@ use Codeception\AssertThrows; use ADT\BackgroundQueue\BackgroundQueue; use Codeception\Test\Unit; +use Doctrine\DBAL\DriverManager; use Tests\Support\Helper\Producer; use Tests\Support\IntegrationTester; @@ -45,7 +46,7 @@ public function testGetPrioritiesListBasedConfig_ok(array $prioritiesAll, ?strin public function providerGetPrioritiesListBasedConfig_error() { return [ [[10, 15, 20, 25, 30, 35, 40], '5', 'Priority 5 is not in available priorities [10,15,20,25,30,35,40]'], - [[10, 30, 35, 40], '20-25', 'Priority 20- has not intersections with availables priorities [10,30,35,40]'], + [[10, 30, 35, 40], '20-25', 'Priority 20-25 has not intersections with availables priorities [10,30,35,40]'], ]; } @@ -73,7 +74,7 @@ private function getConsumeCommand(array $prioritiesAll): ConsumeCommand { $backgroundQueue = new BackgroundQueue([ 'queue' => 'general', 'priorities' => $prioritiesAll, - 'connection' => self::getDsn(), + 'connection' => DriverManager::getConnection(BackgroundQueue::parseDsn(self::getDsn())), 'logger' => null, 'producer' => null ]); diff --git a/tests/Support/Helper/Logger.php b/tests/Support/Helper/Logger.php index 78fd52e..6625cf8 100644 --- a/tests/Support/Helper/Logger.php +++ b/tests/Support/Helper/Logger.php @@ -7,47 +7,47 @@ class Logger implements LoggerInterface { - public function emergency(\Stringable|string $message, array $context = []) + public function emergency(\Stringable|string $message, array $context = []): void { // TODO: Implement emergency() method. } - public function alert(\Stringable|string $message, array $context = []) + public function alert(\Stringable|string $message, array $context = []): void { // TODO: Implement alert() method. } - public function critical(\Stringable|string $message, array $context = []) + public function critical(\Stringable|string $message, array $context = []): void { // TODO: Implement critical() method. } - public function error(\Stringable|string $message, array $context = []) + public function error(\Stringable|string $message, array $context = []): void { // TODO: Implement error() method. } - public function warning(\Stringable|string $message, array $context = []) + public function warning(\Stringable|string $message, array $context = []): void { // TODO: Implement warning() method. } - public function notice(\Stringable|string $message, array $context = []) + public function notice(\Stringable|string $message, array $context = []): void { // TODO: Implement notice() method. } - public function info(\Stringable|string $message, array $context = []) + public function info(\Stringable|string $message, array $context = []): void { // TODO: Implement info() method. } - public function debug(\Stringable|string $message, array $context = []) + public function debug(\Stringable|string $message, array $context = []): void { // TODO: Implement debug() method. } - public function log($level, \Stringable|string $message, array $context = []) + public function log($level, \Stringable|string $message, array $context = []): void { throw new \Exception(explode(" in ", $message)[0]); } diff --git a/tests/Support/Helper/Producer.php b/tests/Support/Helper/Producer.php index 2ae76ad..9b50ac9 100644 --- a/tests/Support/Helper/Producer.php +++ b/tests/Support/Helper/Producer.php @@ -13,7 +13,7 @@ class Producer implements \ADT\BackgroundQueue\Broker\Producer public ?AMQPChannel $channel = null; private array $initQueues = []; - public function publish(int $id, string $queue, ?int $expiration = null): void + public function publish(string $id, string $queue, int $priority, ?int $expiration = null): void { $this->initQueue($queue); @@ -21,6 +21,11 @@ public function publish(int $id, string $queue, ?int $expiration = null): void $this->getChannel()->wait_for_pending_acks(); } + public function publishDie(string $queue): void + { + + } + public function publishNoop(): void { From 2e6fac1319968c9e0ca2bbf9f38285c40238e17f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Ma=C5=A1=C3=AD=C4=8Dek?= Date: Thu, 18 Jun 2026 15:24:20 +0200 Subject: [PATCH 06/15] Add detailed test plan for priority + serialGroup, including a full broker-mode test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands the "Návrh testů" section in docs/priority-serialgroup.md from a single reproduction test into a detailed set of six tests covering every aspect of the proposed solution: - Test 1: Jobs without serialGroup (regression - nothing should change) - Test 2a/2b: Ordering by (priority, ID) - unit + end-to-end cron mode - Test 3: PROCESSING clause (mutual exclusion) - serialization regression - Test 4: Group lock - acquire/release primitive with two connections - Test 5: Conditional claim in save() - RabbitMQ redelivery race - Test 6: Full broker-mode end-to-end - whole loop process → publish → consume → waiting Also documents the prerequisites: extending getBackgroundQueue() (priorities), extending the Mailer helper (processRecording + static $processOrder), direct raw DB connection access to simulate states, and extending the Producer helper (priority queues for Test 6). Coverage includes the interaction with _processWaitingJobs() and findOldestUnfinishedJobIdsByGroup() that the other tests do not exercise on their own. --- docs/priority-serialgroup.md | 541 +++++++++++++++++++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 docs/priority-serialgroup.md diff --git a/docs/priority-serialgroup.md b/docs/priority-serialgroup.md new file mode 100644 index 0000000..2c20895 --- /dev/null +++ b/docs/priority-serialgroup.md @@ -0,0 +1,541 @@ +# Analýza požadavku: priority + serialGroup + +## Zadání + +Záznamy v jedné frontě (např. `transcribe`) sdílejí stejnou `serialGroup` a mají se +odbavovat striktně jeden po druhém (sériově), i přes více serverů (2 servery, na každém +jeden konzument). Záznamy ale mají různé priority a je potřeba, aby **priorita měla +přednost**: nejdřív se mají odbavit všechny s prioritou 1, teprve pak s prioritou 2. + +Cílová sémantika (potvrzeno v diskuzi): + +- **serialGroup = vzájemné vyloučení** (sériovost) - z jedné skupiny běží vždy max. jeden + job současně, napříč všemi konzumenty/servery. Neurčuje pořadí. +- **priorita = pořadí** - z čekajících jobů jde na řadu nejdřív vyšší priorita (nižší číslo), + při shodě priority FIFO podle ID. + +Pro frontu `a1 b1 c2 d1 e1 f2 g1` (vše jedna skupina, čísla = priorita) je cílové pořadí +zpracování `a1 b1 d1 e1 g1 c2 f2`. + +## Současný stav a příčina problému + +**Priorita = oddělené fronty, řazené vzestupně.** V RabbitMQ se priorita modeluje jako +samostatná fronta `_` (`src/Broker/PhpAmqpLib/Producer.php:21-23`). +Konzument je obchází ve vzestupném pořadí priorit (`src/Broker/PhpAmqpLib/Consumer.php:24-37`), +takže nižší číslo = vyšší přednost a priorita 1 se odbavuje před prioritou 2. + +**serialGroup = striktní řazení podle ID.** Při zpracování jobu se `serialGroup` volá +`checkUnfinishedJobs()` (`src/BackgroundQueue.php:714-733`) → +`getPreviousUnfinishedJobId()` → `findOldestUnfinishedJobIdsByGroup()` +(`src/BackgroundQueue.php:594-620`). Ta hledá jakýkoli nedokončený job ve stejné skupině +s **`id < :id`** (řádek 607-608). Pokud existuje, aktuální job jde do `STATE_WAITING`. +Tedy: job čeká na *kterýkoli* job s nižším ID ve skupině, **bez ohledu na prioritu**. + +### Důsledek (pozorovaný problém) + +Tyto dvě pravidla si odporují. Skupina `transcribe`, joby vznikají v pořadí: + +- ID 1, priorita 2 (do fronty `transcribe_2`) +- ID 2, priorita 1 (do fronty `transcribe_1`) + +1. Konzument preferuje `transcribe_1` → vezme ID 2 (prio 1). `checkUnfinishedJobs` najde + starší ID 1 (`id < 2`) → **ID 2 jde do WAITING**, ačkoli má vyšší prioritu. +2. ID 1 (prio 2) se z `transcribe_2` nedostane ke slovu, dokud konzument upřednostňuje + prioritu 1, kam stále přitékají joby, které okamžitě skončí ve WAITING. +3. `_processWaitingJobs` WAITING joby vrací zpět do jejich prioritní fronty → smyčka se + opakuje. + +Výsledkem je, že priorita je fakticky ignorována / invertována: starší job s horší +prioritou (nižší ID) blokuje novější joby s vyšší prioritou. + +### Existující race condition - síťový výpadek konzumenta (nezávislá na prioritách) + +I bez priorit existuje race, která platí již dnes: RabbitMQ doručí totéž ID jobu dvěma +konzumentům, pokud konzumentu A vypadne TCP spojení s RabbitMQ (síťový hiccup, restart +klienta) zatímco job zpracovává. RabbitMQ označí zprávu za nedoručenou a doručí ji +konzumentu B - přitom konzument A stále běží a job také zpracovává. + +Ochrana existuje: `isReadyForProcess()` (`src/BackgroundQueue.php:243`) odstaví konzumenta +B, pokud A stihl zapsat `STATE_PROCESSING` do DB. Ale mezi `getEntity()` (řádek 237) a +`save()` (řádek 284) je check-then-act okénko - pokud B získá job v tomto okamžiku, oba +projdou a tentýž job běží dvakrát. + +Opravuje to podmíněný claim: `UPDATE ... WHERE id = :id AND state IN (ready stavy)` + +kontrola `affectedRows == 1` (viz Cesta A, bod 4 níže). Tato oprava je nezávislá na +prioritách a smysluplná i sama o sobě. + +## Klíčové zjištění k řešení + +Problém nelze vyřešit pouhou záměnou řazení `id` → `(priorita, id)`. + +### Co dnes drží sériovost "zadarmo" + +Uvnitř `serialGroup` se řadí výhradně podle ID: job počká, pokud ve skupině existuje +jakýkoli nedokončený job s nižším ID (`id < :id` v `findOldestUnfinishedJobIdsByGroup()`, +`src/BackgroundQueue.php:607-608`). + +Protože autoincrement ID jen roste, platí jednoduchá rovnice: + +> **běžící (PROCESSING) job = ten, co začal nejdřív = má nejnižší ID ve skupině +> = je předchůdcem úplně všech ostatních.** + +Takže každý nově příchozí job má vždy vyšší ID než ten běžící, uvidí ho přes `id < :id` +jako svého předchůdce a počká ve `STATE_WAITING`. Vzájemné vyloučení tu vůbec není +explicitně naprogramované - vzniká jako **vedlejší efekt řazení podle ID**. Ordering +(pořadí) a exclusion (max. jeden běžící) jsou dnes jedna a táž podmínka. + +### Proč to padne při řazení podle priority + +Jakmile o pořadí začne rozhodovat `(priorita, ID)`, ta rovnice přestane platit: + +- Běží job s **horší prioritou** (vyšší číslo), který má ale **nízké ID** (přišel dřív). +- Pak přijde nový job s **lepší prioritou** (nižší číslo), nutně s **vyšším ID** (přišel + později). + +Nový job má jít před ten běžící (lepší priorita). Podmínka +`(priority < :priority OR (priority = :priority AND id < :id))` ho neblokuje - běžící +job má horší prioritu, takže z pohledu pořadí není předchůdce. Nový job tedy nepočká a +rozjede se souběžně s běžícím → **dva joby téže skupiny běží naráz → porušení +sériovosti**. + +**Závěr:** Z jedné podmínky se musí stát dvě oddělené věci: + +1. **Ordering** - "kdo jde na řadu" → `(priorita, ID)`. +2. **Mutual exclusion** - "nikdy dva naráz" → musí se vynutit zvlášť, protože už ji + ordering "nenese s sebou". + +To řeší obě navržené cesty: Cesta A přidává explicitní klauzuli na `state = PROCESSING` +(bod 2 níže) plus zámek na skupinu, Cesta B dělá exclusion strukturálně tím, že do +brokera pustí vždy jen jednu hlavu skupiny. + +--- + +## Návrh řešení: změna řazení + zámek + +### Princip + +Uvnitř `serialGroup` přestaneme řadit podle ID a začneme řadit podle `(priorita, ID)`. +Sériovost zajistíme explicitní kontrolou běžícího jobu + zámkem, aby se to nerozbilo +při souběhu konzumentů. + +### Návrh implementace + +**1) Výběr předchůdce dle `(priorita, ID)`** + +V `findOldestUnfinishedJobIdsByGroup()` (`src/BackgroundQueue.php:594-620`) nahradit +podmínku `id < :id` (řádek 607-608) za lexikografické porovnání a řadit/agregovat podle +`(priority, id)` místo jen `id`: + +```sql +(priority < :priority OR (priority = :priority AND id < :id)) +``` + +Job pak čeká, jen pokud ve skupině existuje nedokončený job, který má jít před ním +(vyšší priorita, nebo stejná priorita a nižší ID). + +**2) Klauzule na PROCESSING (vzájemné vyloučení)** + +Do téhož dotazu přidat: job počká **vždy**, když je ve skupině jakýkoli job ve stavu +`PROCESSING`, bez ohledu na prioritu: + +```sql +... AND id <> :id AND ( + state = PROCESSING + OR (priority < :priority OR (priority = :priority AND id < :id)) +) +``` + +Bez této klauzule by později vložený job s vyšší prioritou nepoznal běžící +nižší-prioritní job jako překážku a naběhl by souběžně. Tato část je **povinná** - +bez ní oprava zanáší regresi sériovosti, která dnes neexistuje. + +Příklad kolize bez klauzule na PROCESSING (nevyžaduje souběh - stane se vždy): + +| Čas | Konzument A | Konzument B | DB stav | +|-----|-------------|-------------|---------| +| T1 | zpracovává ID 1 (prio 2), callback běží | - | ID 1 = PROCESSING | +| T2 | - | `checkUnfinishedJobs(ID 2, prio 1)`: hledá job kde `(priority < 1 OR (priority=1 AND id < 2))` → ID 1 má prioritu 2, nesplňuje ani jednu větev → **žádný předchůdce** | ID 1 = PROCESSING | +| T3 | - | zapisuje PROCESSING pro ID 2, spouští callback | ID 1 = PROCESSING, **ID 2 = PROCESSING** | + +Oba joby skupiny `transcribe` běží naráz. S klauzulí `OR state = PROCESSING` B v T2 ID 1 +najde a správně jde do WAITING: + +| Čas | Konzument A | Konzument B | DB stav | +|-----|-------------|-------------|---------| +| T1 | zpracovává ID 1 (prio 2), callback běží | - | ID 1 = PROCESSING | +| T2 | - | `checkUnfinishedJobs(ID 2, prio 1)`: `state = PROCESSING` → **ID 1 je blocker → jde do WAITING** | ID 1 = PROCESSING | +| T3 | dokončí, zapisuje FINISHED | - | ID 1 = FINISHED, ID 2 = WAITING | +| T4 | - | `_processWaitingJobs` přepne ID 2 na READY, publikuje do brokera | ID 2 = READY | +| T5 | - | konzument převezme ID 2, spouští callback | ID 2 = PROCESSING | + +**3) Zámek na skupinu kolem kritické sekce** + +`processJob()` (`src/BackgroundQueue.php:232-284`) je dnes čistý *check-then-act*: +nejdřív `checkUnfinishedJobs()` (řádek 254), pak až o kus dál `setState(PROCESSING)` ++ `save()` (272-284), mezi tím není zámek. Dva konzumenti tak mohou projít kontrolou +současně. + +Řešení: kolem **[checkUnfinishedJobs + zápis PROCESSING]** dát pojmenovaný zámek na +skupinu - MySQL `GET_LOCK('bgq:'+serialGroup)` na začátku, `RELEASE_LOCK` po zápisu. +Drží se jen pár mikrosekund (ne po dobu zpracování callbacku). `processJob` má vlastní +`createConnection()` (řádek 235), takže connection-scoped zámek sedí. Serializuje jen +konzumenty téže skupiny, různé skupiny se neperou. + +**Abstrakce pro MySQL i PostgreSQL** + +Advisory locky jsou v obou DB různé, ale logika je stejná. Řešení: dvě privátní metody +na `BackgroundQueue`, které detekují platformu přes Doctrine DBAL a zavolají správný SQL. + +Detekce platformy: + +```php +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; + +$platform = $this->connection->getDatabasePlatform(); +``` + +MySQL (`GET_LOCK` / `RELEASE_LOCK`): + +```php +private function acquireGroupLock(string $serialGroup): void +{ + $acquired = $this->connection->fetchOne( + "SELECT GET_LOCK(?, 10)", + ['bgq:' . $serialGroup] + ); + if (!$acquired) { + throw new \RuntimeException('Could not acquire serial group lock for: ' . $serialGroup); + } +} + +private function releaseGroupLock(string $serialGroup): void +{ + $this->connection->executeStatement( + "SELECT RELEASE_LOCK(?)", + ['bgq:' . $serialGroup] + ); +} +``` + +PostgreSQL (`pg_advisory_lock` / `pg_advisory_unlock`): + +```php +private function acquireGroupLock(string $serialGroup): void +{ + // pg_advisory_lock vyžaduje integer klíče - použijeme fixní namespace + crc32 skupiny. + // Timeout přes lock_timeout session proměnnou (platí jen pro toto volání). + $this->connection->executeStatement("SET LOCAL lock_timeout = '10s'"); + $this->connection->executeStatement( + "SELECT pg_advisory_lock(?, ?)", + [self::PG_LOCK_NAMESPACE, crc32($serialGroup)] + ); +} + +private function releaseGroupLock(string $serialGroup): void +{ + $this->connection->executeStatement( + "SELECT pg_advisory_unlock(?, ?)", + [self::PG_LOCK_NAMESPACE, crc32($serialGroup)] + ); +} +``` + +Konstanta `PG_LOCK_NAMESPACE` (např. `0x42475100` = ASCII `BGQ\0`) odděluje klíče +background-queue od ostatních advisory locků v aplikaci. `crc32($serialGroup)` vrací +int32 - pro jména skupin v rámci jedné aplikace je pravděpodobnost kolize zanedbatelná; +při potřebě vyšší jistoty lze použít `abs(crc32(...))` nebo dedikovanou číselnou řadu. + +`processJob` pak volá `acquireGroupLock` / `releaseGroupLock` beze znalosti DB platformy +a switch logika žije pouze v těchto dvou metodách. + +Příklad kolize bez zámku (A má horší prioritu, B přijde dřív než A zapsal PROCESSING): + +| Čas | Konzument A | Konzument B | DB stav | +|-----|-------------|-------------|---------| +| T1 | `checkUnfinishedJobs(ID 1, prio 2)`: žádný předchůdce → **projde** | - | ID 1 = READY | +| T2 | *(ještě nepsal PROCESSING)* | `checkUnfinishedJobs(ID 2, prio 1)`: ID 1 má prio 2 (nesplňuje `priority < 1`), prio 2 ≠ 1 (nesplňuje ordering), stav = READY (ne PROCESSING) → **žádný předchůdce → projde** | ID 1 = READY | +| T3 | zapisuje PROCESSING pro ID 1 | *(již po checku)* | ID 1 = PROCESSING | +| T4 | - | zapisuje PROCESSING pro ID 2 | ID 1 = PROCESSING, **ID 2 = PROCESSING** | + +Se zámkem B v T2 čeká, než A dokončí celou sekvenci [check → zápis PROCESSING]. Až pak +provede svůj check - a tehdy ID 1 už je PROCESSING → B správně jde do WAITING: + +| Čas | Konzument A | Konzument B | DB stav | +|-----|-------------|-------------|---------| +| T1 | `GET_LOCK('bgq:transcribe')` → získá | - | ID 1 = READY | +| T2 | `checkUnfinishedJobs(ID 1, prio 2)` → projde | `GET_LOCK('bgq:transcribe')` → **čeká** | ID 1 = READY | +| T3 | zapisuje PROCESSING pro ID 1 | *(stále čeká)* | ID 1 = PROCESSING | +| T4 | `RELEASE_LOCK` | - | ID 1 = PROCESSING | +| T5 | - | získá zámek | ID 1 = PROCESSING | +| T6 | - | `checkUnfinishedJobs(ID 2, prio 1)`: `state = PROCESSING` → **ID 1 je blocker → jde do WAITING** | ID 1 = PROCESSING | +| T7 | - | `RELEASE_LOCK` | ID 1 = PROCESSING, ID 2 = WAITING | + +**4) Podmíněný claim (levná pojistka)** + +Zámek (bod 3) chrání kritickou sekci `[checkUnfinishedJobs → zápis PROCESSING]` uvnitř +jednoho `processJob()` volání. Ale existuje scénář, který ho obejde: **tentýž job ID +doručený dvěma konzumentům** (RabbitMQ redelivery při výpadku spojení konzumenta). + +Dnes `save()` (`src/BackgroundQueue.php:643`) dělá: + +```sql +UPDATE background_job SET state = PROCESSING, ... WHERE id = :id +``` + +Bez podmínky na stav. Takže pokud konzument A drží job a zpracovává ho, ale jeho TCP +spojení s RabbitMQ vypadne - RabbitMQ doručí totéž ID konzumentu B. B projde +`isReadyForProcess()` (job je stále PROCESSING → vrátí `false`) a zastaví se. **Ale +pouze pokud A stihl zapsat PROCESSING.** Pokud B dostane zprávu dřív, než A zapsal: + +| Čas | Konzument A | Konzument B | DB stav | +|-----|-------------|-------------|---------| +| T1 | `isReadyForProcess()` → READY → OK | - | ID 1 = READY | +| T2 | *(ještě nepsal PROCESSING)* | dostane redelivery ID 1, `isReadyForProcess()` → READY → OK | ID 1 = READY | +| T3 | `UPDATE SET state=PROCESSING WHERE id=1` → zapíše | - | ID 1 = PROCESSING | +| T4 | spouští callback | `UPDATE SET state=PROCESSING WHERE id=1` → **také zapíše** (bez podmínky na stav!) | ID 1 = PROCESSING | +| T5 | callback běží | callback také běží | **oba zpracovávají ID 1** | + +Oprava: doplnit podmínku na stav a kontrolovat `affectedRows`: + +```sql +UPDATE background_job SET state = PROCESSING, ... WHERE id = :id AND state IN (1, 4, -1) +-- STATE_READY=1, STATE_TEMPORARILY_FAILED=4, STATE_BACK_TO_BROKER=-1 +``` + +```php +$affected = $this->connection->update( + $this->config['tableName'], + $entity->getDatabaseValues(), + ['id' => $entity->getId(), 'state' => $readyStates] // WHERE id = ? AND state IN (?) +); +if ($affected === 0) { + return; // job mezitím sebral nebo změnil stav jiný konzument +} +``` + +Pokud `affectedRows === 0`, job mezitím přešel do jiného stavu (jiný konzument ho +zpracovává nebo dokončil) → aktuální konzument tiše vrátí `return`. Chrání i mimo +`serialGroup` - platí pro jakýkoli job doručený vícekrát. + +Jde o **laciné pojistné patro navíc**: UPDATE je atomický na úrovni DB, takže i při +dokonalém souběhu T3 a T4 může podmíněný UPDATE uspět jen jednomu z konzumentů. + +### Náročnost / indexy + +**Souhrn:** Problém s náročností při velkém počtu nedokončených jobů existuje už dnes - +nevzniká novým řešením. Nová podmínka (`priority`, `PROCESSING`) na tom nic nemění, protože +jde o filtry vyhodnocené na řádcích, které už beztak vybral index `state`. + +Přidané podmínky (`priority`, `PROCESSING`) jsou residual filtry na řádcích, které už +vybral index `state` (`src/BackgroundQueue.php:457-459` zakládá indexy jen na `id`, `identifier`, +`state`). **Žádný nový index nepotřebují, plán dotazu se nemění.** Náklad je daný počtem +nedokončených jobů, ne velikostí tabulky. (Pokud by se nedokončené joby hromadily do +velkých čísel, šlo by zvážit kompozitní index `(serial_group, state, priority, id)`, ale +to je nezávislé na této opravě.) + +### Vlastnosti + +- **Plus:** malý, lokální zásah - jádro je úprava jednoho dotazu. +- **Plus:** přenositelnost MySQL/PostgreSQL řeší abstrakce `acquireGroupLock` / + `releaseGroupLock` s detekcí platformy přes Doctrine DBAL (viz výše). +- **Minus:** závislost na DB advisory locku - přidává další synchronizační primitiv. +- Vzájemné vyloučení zůstává vynucováno "líně" při konzumaci. + +## Joby bez serialGroup + +Joby bez `serialGroup` zůstávají beze změny: `checkUnfinishedJobs()` +(`src/BackgroundQueue.php:716-718`) pro ně hned dělá `return true`, takže nikdy nejdou do +WAITING ani nehledají předchůdce. Body 1-3 (řazení, klauzule na `PROCESSING`, zámek na +skupinu) se jich tedy netýkají - zámek se navíc bere jen je-li `serialGroup` nastavena. +Jediný dotyk je bod 4 (podmíněný claim v `save()`), který platí pro všechny joby jako +atomické převzetí ke zpracování; publikování zůstává netknuté. + +## Návrh testů + +Všechny testy patří do jediné existující suite `Integration` +(`tests/Integration/BackgroundQueueTest.php`). Drží se zavedených patternů: data providery +parametrizované přes `producer`/`waitingQueue`, reflexe na privátní metody +(`new \ReflectionClass(BackgroundQueue::class)`, jako u `testCheckUnfinishedJobs`, +`tests/Integration/BackgroundQueueTest.php:237-257`) a helper `fetchAllJobs()` pro čtení +stavu jobů z DB. + +### Předpoklady - úprava helperů + +Bez následujících úprav testy níže nelze napsat: + +1. **`getBackgroundQueue()`** (`tests/Integration/BackgroundQueueTest.php:278`) - přidat + parametr `priorities` (default `[1]` jako dnes) a propsat ho do konfigurace, ať lze + publikovat na různé priority. +2. **`Mailer` helper** (`tests/Support/Helper/Mailer.php`) - přidat callback, který si + parametr (značku jobu) připíše do sdíleného statického pole, např.: + + ```php + public static array $processOrder = []; + + public function processRecording(string $mark): void + { + self::$processOrder[] = $mark; + } + ``` + + Pole se v `_before()` / `clear()` musí vynulovat, aby testy byly nezávislé. +3. **Přímý přístup k DB connection** v testu (jako `clear()`, + `tests/Integration/BackgroundQueueTest.php:317`) pro ruční nastavení stavu/priority + řádku - tím se deterministicky nasimulují stavy, které by jinak vznikly jen souběhem + (běžící `PROCESSING` job, job sebraný jiným konzumentem). +4. **`Producer` helper** (`tests/Support/Helper/Producer.php`) - pouze pro Test 6. + `consume()` (řádek 35) i `getMessageCount()` (řádek 49) dnes natvrdo pracují s frontou + `'general'` a neumí prioritní fronty `_` (`general_1`, `general_2`). + Pro broker-mode test je nutné je rozšířit tak, aby konzumovaly z prioritních front ve + správném pořadí (nižší číslo dřív), jak to dělá reálný `Consumer` + (`src/Broker/PhpAmqpLib/Consumer.php:24-37`). + +Každý test je psán tak, aby **před opravou padal (červená) a po opravě prošel (zelená)**, +kromě testu bez `serialGroup` a testu zámkového primitiva, které ověřují, že se nic +nerozbilo / že nová abstrakce funguje. + +### Test 1 - joby bez serialGroup (regrese, kapitola „Joby bez serialGroup") + +Ověřuje, že se opravy bodů 1-3 jobů bez `serialGroup` vůbec nedotknou. + +- Publikovat několik jobů **bez** `serialGroup`, s proloženými prioritami (např. prio 2, + prio 1, prio 2). +- Reflexí zavolat `checkUnfinishedJobs()` (`src/BackgroundQueue.php:714`) na každý z nich. +- **Aser:** vždy vrací `true`, žádný job neskončí ve `STATE_WAITING` (řádek 716-718 dělá + `return true` hned na vstupu). +- Doplnit běh `process()` (cron mód, bez produceru) a ověřit, že se všechny zpracují + (`STATE_FINISHED`) - pořadí mezi nimi se neřeší, sériovost se neuplatňuje. + +### Test 2 - řazení podle (priorita, ID) uvnitř skupiny (bod 1) + +Jádro opravy a hlavní reprodukce. Lze pojmout dvěma vrstvami, doporučuji obě: + +**2a) Jednotka nad `getPreviousUnfinishedJobId()`** (`src/BackgroundQueue.php:582`, +přes reflexi). Do jedné `serialGroup` vložit joby a ručně jim nastavit priority, pak +ověřit, koho dotaz označí za předchůdce: + +- job A: ID 1, prio 2; job B: ID 2, prio 1 (vyšší priorita, přišel později). +- `getPreviousUnfinishedJobId(B)` → **`null`** (B nemá jít po nikom: nikdo nemá vyšší + prioritu ani stejnou prioritu s nižším ID). +- `getPreviousUnfinishedJobId(A)` → **ID 2** (B má jít před A). +- Před opravou je výsledek opačný (řadí se jen podle `id < :id`, řádek 607-608), takže + test červeně reprodukuje bug. + +**2b) End-to-end pořadí přes `process()` v cron módu (bez produceru).** Cron mód je pro +tento test čistě deterministický: `process()` (`src/BackgroundQueue.php:184`) projde +zpracovatelné joby, hlava skupiny se zpracuje a zbytek skupiny jde do `STATE_WAITING`; +opakovaným voláním `process()` (WAITING je v cron módu zpracovatelný stav) se postupně +odbaví celá skupina. + +- Konfigurace `priorities` `[1, 2]`, callback `processRecording`, jako značku použít + identifikátor jobu (`a`, `b`, ...). +- Do jedné `serialGroup` publikovat kanonický příklad ze zadání: + `a1 b1 c2 d1 e1 f2 g1` (písmeno = pořadí vložení, číslo = priorita). +- Volat `process()` v cyklu (např. dokud existují nedokončené joby, s pojistným stropem + iterací). +- **Aser:** `Mailer::$processOrder === ['a','b','d','e','g','c','f']` (priorita ASC, při + shodě ID ASC). +- **Aser sériovosti:** v žádném okamžiku neběží dva joby skupiny naráz - v cron módu to + plyne z toho, že `process()` zpracovává joby inline jeden po druhém; lze ověřit nepřímo + tím, že do `STATE_PROCESSING` se nikdy nedostanou dva řádky téže skupiny současně + (kontrola uvnitř `processRecording` přes `fetchAllJobs`). + +### Test 3 - klauzule na PROCESSING = vzájemné vyloučení (bod 2) + +Ověřuje scénář z tabulky v bodě 2 (běžící nižší-prioritní job musí zablokovat novější +job s vyšší prioritou). Deterministicky, bez souběhu, ruční manipulací stavu v DB: + +- Do jedné `serialGroup` publikovat job A (ID 1, prio 2) a job B (ID 2, prio 1). +- Ručně (raw connection) nastavit A na `STATE_PROCESSING` (simulace běžícího callbacku). +- Reflexí zavolat `checkUnfinishedJobs(B)` (`src/BackgroundQueue.php:714`). +- **Aser:** vrací `false` a B přejde do `STATE_WAITING`, **ačkoli A není ordering-předchůdce** + (A má horší prioritu). Blok vzniká jen díky klauzuli `OR state = PROCESSING`. +- Bez této klauzule (a po pouhé opravě bodu 1) by `checkUnfinishedJobs(B)` vrátil `true` + a vznikla by regrese sériovosti, kterou tento test chytí. +- Doplňkový případ: A ve `STATE_FINISHED` → `checkUnfinishedJobs(B)` vrací `true` + (dokončený job není překážka). + +### Test 4 - zámek na skupinu (bod 3) + +Plný race check-then-act (dva konzumenti projdou kontrolou současně) se v jednovláknovém +PHPUnit procesu **deterministicky nereprodukuje** - tady to v testech přiznáváme a +ověřujeme jen to, že zámkové primitivum funguje. Reálné vzájemné vyloučení za souběhu pak +fakticky stojí na tomto primitivu plus na podmíněném claimu (Test 5). + +Test samotného primitiva (`acquireGroupLock` / `releaseGroupLock`, přes reflexi), nezávisle +na platformě (poběží proti MySQL z CI): + +- Vytvořit **dvě samostatné** DBAL connection ze stejného DSN (jako `processJob` přes + `createConnection()`, `src/BackgroundQueue.php:235`). +- Na connection č. 1 `acquireGroupLock('skupina')` → uspěje. +- Na connection č. 2 `acquireGroupLock('skupina')` s krátkým timeoutem → **neuspěje / + zablokuje se** (u MySQL `GET_LOCK(..., timeout)` vrátí 0 → metoda vyhodí `RuntimeException`). +- Na connection č. 1 `releaseGroupLock('skupina')`. +- Na connection č. 2 `acquireGroupLock('skupina')` → nyní **uspěje**. +- Druhý případ: dvě **různé** skupiny se navzájem neblokují (obě connection získají zámek + každá pro svou skupinu) - ověří, že serializujeme jen v rámci jedné skupiny. + +### Test 5 - podmíněný claim v save() (bod 4) + +Ověřuje atomické převzetí jobu (RabbitMQ redelivery doručí totéž ID dvakrát). Race mezi +dvěma `UPDATE` se nasimuluje deterministicky tím, že DB řádek přepneme „pod rukama" do +ne-ready stavu, zatímco v paměti držíme starou (READY) entitu: + +- Publikovat job bez `serialGroup` s callbackem `processRecording`. +- Načíst entitu (`fetchAllJobs`) - v paměti je `STATE_READY`. +- Raw connection přepnout řádek v DB na `STATE_FINISHED` (případně `STATE_PROCESSING`) - + simulace „job mezitím sebral jiný konzument". +- Zavolat `processJob(id)` (`src/BackgroundQueue.php:232`). +- **Aser:** callback se **nespustil** (`Mailer::$processOrder` je prázdné) a stav v DB se + z `STATE_FINISHED` nezměnil - podmíněný `UPDATE ... WHERE id = :id AND state IN (ready)` + vrátil `affectedRows === 0` a `processJob` tiše skončil. +- Negativní kontrola (job je READY): běžný job projde, `affectedRows === 1`, callback se + spustí, stav je `STATE_FINISHED` - ověří, že claim nezablokoval normální cestu. +- Tento test platí i mimo `serialGroup` (claim je společný pro všechny joby, viz kapitola + „Joby bez serialGroup"). + +### Test 6 - komplexní broker-mode end-to-end (celá smyčka) + +Jediný „velký" test. Ostatní testy jsou izolované a deterministické, ale pozorovaný problém +se projevuje **v broker módu** (kapitola „Důsledek", `docs/priority-serialgroup.md:41-46`), +ne v cron módu. Tenhle test jako jediný projede celou produkční smyčku +`process()` → publikace do prioritní fronty → konzumace → `checkUnfinishedJobs` → WAITING → +`_processWaitingJobs` → zpět na READY → znovupublikace. Tím zároveň pokryje interakci s +`processWaitingJobs()` (`src/BackgroundQueue.php:768`), která sdílí +`findOldestUnfinishedJobIdsByGroup()` (řádek 594) - žádný z testů 1-5 ji neprověří. + +- Konfigurace: `producer` zapnutý, `priorities` `[1, 2]`, callback `processRecording`, + jako značku použít identifikátor jobu. +- Do jedné `serialGroup` publikovat kanonický příklad `a1 b1 c2 d1 e1 f2 g1`. +- V cyklu (s pojistným stropem iterací) opakovat: + 1. `process()` - v broker módu přepne způsobilé řádky na READY a publikuje jejich ID do + prioritních front; zároveň zaregistruje interní `_processWaitingJobs` + (`src/BackgroundQueue.php:192-194`). + 2. konzumovat dostupné zprávy přes rozšířený `Producer` helper (prioritní fronty v pořadí + priorit) a na každé ID zavolat `processJob()` - to je cesta, kterou v provozu jede + `Consumer::consume()`. + 3. nechat proběhnout `_processWaitingJobs` (zkonzumovat a zpracovat i jeho job), aby se + nejstarší WAITING hlava skupiny vrátila na READY. + - cyklus končí, až nejsou žádné nedokončené joby. +- **Aser pořadí:** `Mailer::$processOrder === ['a','b','d','e','g','c','f']` (priorita ASC, + při shodě ID ASC) - stejný cíl jako 2b, ale přes reálnou broker cestu. +- **Aser sériovosti:** v žádném okamžiku nejsou dva joby téže skupiny ve `STATE_PROCESSING` + současně (kontrola uvnitř `processRecording` přes `fetchAllJobs`). +- Před opravou červeně reprodukuje invertovanou prioritu z kapitoly „Důsledek", po opravě + zezelená. + +Pozn.: test vyžaduje běžící RabbitMQ (běží v CI kontejneru `background-queue_rabbitmq`) a +je z celé sady nejdražší a nejcitlivější na časování; proto zůstává jediný svého druhu - +konkrétní větve levněji a stabilněji pokrývají jednotkové testy 2a/3/5. + +### Shrnutí pokrytí + +| Test | Pokrývá | Charakter | +|------|---------|-----------| +| 1 | kapitola „Joby bez serialGroup" | regrese, zelený před i po | +| 2a/2b | bod 1 (řazení dle priority, ID) | reprodukce, červený → zelený | +| 3 | bod 2 (klauzule PROCESSING) | reprodukce regrese sériovosti | +| 4 | bod 3 (zámek na skupinu) | jen primitivum (race nereprodukovatelný single-process) | +| 5 | bod 4 (podmíněný claim) | reprodukce redelivery race | +| 6 | celá broker smyčka + `_processWaitingJobs` | end-to-end, vyžaduje RabbitMQ, nejdražší | From 8ff1063a49a04206f113b75bf3c231dd9b37a32b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Ma=C5=A1=C3=AD=C4=8Dek?= Date: Thu, 18 Jun 2026 17:13:53 +0200 Subject: [PATCH 07/15] Implement integration tests for priority and serialGroup handling Add comprehensive test suite covering priority ordering and serial group semantics: - Test 1: Verify jobs without serialGroup are unaffected by priority changes - Test 2a/2b: Validate predecessor selection by (priority, ID) and order in cron mode - Test 3: Ensure PROCESSING clause blocks higher-priority jobs (mutual exclusion) - Test 4: Guard for acquireGroupLock/releaseGroupLock implementation - Test 5: Guard for conditional claim in save() to prevent double-processing on redelivery - Test 6: Full broker-mode end-to-end test through priority queues and WAITING mechanism Extend test helpers: - Mailer: Add processRecording() callback with $processOrder tracking and serial group violation detection - Producer: Implement priority queue routing (general_), consume in priority order - BackgroundQueueTest: Add priorities parameter, rawConnection() helper, purge priority sub-queues All tests currently demonstrate expected behavior: 4 red (reproduce missing fixes), 3 green (guards). --- tests/Integration/BackgroundQueueTest.php | 288 +++++++++++++++++++++- tests/Support/Helper/Mailer.php | 60 ++++- tests/Support/Helper/Producer.php | 62 ++++- 3 files changed, 398 insertions(+), 12 deletions(-) diff --git a/tests/Integration/BackgroundQueueTest.php b/tests/Integration/BackgroundQueueTest.php index c8b9cae..f35d4b4 100644 --- a/tests/Integration/BackgroundQueueTest.php +++ b/tests/Integration/BackgroundQueueTest.php @@ -30,6 +30,7 @@ class BackgroundQueueTest extends Unit protected function _before() { parent::_before(); + Mailer::reset(); self::clear(); } @@ -255,7 +256,277 @@ public function testCheckUnfinishedJobs(bool $producer, bool $waitingQueue) $this->tester->assertEquals(0, self::$producer->getMessageCount('general'), 'general'); } } - + + /** + * Test 1 - joby bez serialGroup se opravou priority/PROCESSING/zámku vůbec nedotknou. + * Bez serialGroup je každý job okamžitě zpracovatelný a nikdy nejde do WAITING. + * + * @see docs/priority-serialgroup.md "Test 1 - joby bez serialGroup" + * + * @throws ReflectionException + * @throws Exception + */ + public function testPriorityNoSerialGroup() + { + $reflectionClass = new \ReflectionClass(BackgroundQueue::class); + $method = $reflectionClass->getMethod('checkUnfinishedJobs'); + $method->setAccessible(true); + + $backgroundQueue = self::getBackgroundQueue(priorities: [1, 2]); + // proložené priority, žádná serialGroup + $backgroundQueue->publish('processRecording', ['x'], null, null, ModeEnum::NORMAL, null, 2); + $backgroundQueue->publish('processRecording', ['y'], null, null, ModeEnum::NORMAL, null, 1); + $backgroundQueue->publish('processRecording', ['z'], null, null, ModeEnum::NORMAL, null, 2); + + /** @var BackgroundJob[] $backgroundJobs */ + $backgroundJobs = self::fetchAllJobs($backgroundQueue); + foreach ($backgroundJobs as $job) { + $this->tester->assertTrue($method->invoke($backgroundQueue, $job), 'job bez serialGroup je vždy zpracovatelný'); + $this->tester->assertNotEquals(BackgroundJob::STATE_WAITING, $job->getState(), 'nesmí jít do WAITING'); + } + + $backgroundQueue->process(); + + foreach (self::fetchAllJobs($backgroundQueue) as $job) { + $this->tester->assertEquals(BackgroundJob::STATE_FINISHED, $job->getState(), 'vše se zpracuje'); + } + } + + /** + * Test 2a - výběr předchůdce uvnitř skupiny dle (priorita, ID), nikoli jen dle ID. + * + * @see docs/priority-serialgroup.md "Test 2 - řazení podle (priorita, ID) uvnitř skupiny (bod 1)", varianta 2a + * + * @throws ReflectionException + * @throws Exception + */ + public function testGetPreviousUnfinishedJobIdByPriority() + { + $reflectionClass = new \ReflectionClass(BackgroundQueue::class); + $method = $reflectionClass->getMethod('getPreviousUnfinishedJobId'); + $method->setAccessible(true); + + $backgroundQueue = self::getBackgroundQueue(priorities: [1, 2]); + // A: starší (nižší ID), horší priorita; B: novější (vyšší ID), lepší priorita + $backgroundQueue->publish('processRecording', ['a'], 'group2a', null, ModeEnum::NORMAL, null, 2); + $backgroundQueue->publish('processRecording', ['b'], 'group2a', null, ModeEnum::NORMAL, null, 1); + + /** @var BackgroundJob[] $backgroundJobs */ + $backgroundJobs = self::fetchAllJobs($backgroundQueue); + $a = $backgroundJobs[0]; + $b = $backgroundJobs[1]; + + // B má lepší prioritu, nemá jít po nikom + $this->tester->assertNull($method->invoke($backgroundQueue, $b), 'B nemá předchůdce'); + // A má jít až po B + $this->tester->assertEquals($b->getId(), $method->invoke($backgroundQueue, $a), 'předchůdcem A je B'); + } + + /** + * Test 2b - end-to-end pořadí zpracování uvnitř skupiny v cron módu. + * Kanonický příklad a1 b1 c2 d1 e1 f2 g1 -> cílové pořadí a b d e g c f. + * + * @see docs/priority-serialgroup.md "Test 2 - řazení podle (priorita, ID) uvnitř skupiny (bod 1)", varianta 2b + * + * @throws Exception + */ + public function testProcessOrderByPriorityInCronMode() + { + $backgroundQueue = self::getBackgroundQueue(priorities: [1, 2]); + + Mailer::$connection = self::rawConnection(); + Mailer::$tableName = $_ENV['PROJECT_DB_TABLENAME']; + + $jobs = [['a', 1], ['b', 1], ['c', 2], ['d', 1], ['e', 1], ['f', 2], ['g', 1]]; + foreach ($jobs as [$mark, $priority]) { + $backgroundQueue->publish('processRecording', [$mark], 'transcribe', null, ModeEnum::NORMAL, null, $priority); + } + + // V cron módu se WAITING joby zpracují opakovaným voláním process(). + $maxIterations = 20; + while ($maxIterations-- > 0 && self::finishedCount('transcribe') < count($jobs)) { + $backgroundQueue->process(); + } + + $this->tester->assertEquals(['a', 'b', 'd', 'e', 'g', 'c', 'f'], Mailer::$processOrder, 'pořadí dle (priorita, ID)'); + $this->tester->assertFalse(Mailer::$serialGroupViolation, 'sériovost nesmí být porušena'); + } + + /** + * Test 3 - klauzule na PROCESSING zajistí vzájemné vyloučení i proti lepší prioritě. + * Běžící (PROCESSING) job musí zablokovat novější job s vyšší prioritou, ačkoli není ordering-předchůdce. + * + * @see docs/priority-serialgroup.md "Test 3 - klauzule na PROCESSING = vzájemné vyloučení (bod 2)" + * + * @throws ReflectionException + * @throws Exception + */ + public function testProcessingClauseBlocksHigherPriority() + { + $reflectionClass = new \ReflectionClass(BackgroundQueue::class); + $method = $reflectionClass->getMethod('checkUnfinishedJobs'); + $method->setAccessible(true); + + $backgroundQueue = self::getBackgroundQueue(priorities: [1, 2]); + // A: starší, horší priorita; B: novější, lepší priorita + $backgroundQueue->publish('processRecording', ['a'], 'group3', null, ModeEnum::NORMAL, null, 2); + $backgroundQueue->publish('processRecording', ['b'], 'group3', null, ModeEnum::NORMAL, null, 1); + + /** @var BackgroundJob[] $backgroundJobs */ + $backgroundJobs = self::fetchAllJobs($backgroundQueue); + $a = $backgroundJobs[0]; + $b = $backgroundJobs[1]; + + // A "běží" (PROCESSING) -> B musí počkat, přestože má lepší prioritu + self::rawConnection()->update($_ENV['PROJECT_DB_TABLENAME'], ['state' => BackgroundJob::STATE_PROCESSING], ['id' => $a->getId()]); + $this->tester->assertFalse($method->invoke($backgroundQueue, $b), 'běžící A blokuje B'); + $this->tester->assertEquals(BackgroundJob::STATE_WAITING, self::fetchJob($backgroundQueue, $b->getId())->getState(), 'B jde do WAITING'); + + // A dokončeno -> už není překážka + self::rawConnection()->update($_ENV['PROJECT_DB_TABLENAME'], ['state' => BackgroundJob::STATE_FINISHED], ['id' => $a->getId()]); + self::rawConnection()->update($_ENV['PROJECT_DB_TABLENAME'], ['state' => BackgroundJob::STATE_READY], ['id' => $b->getId()]); + $this->tester->assertTrue($method->invoke($backgroundQueue, self::fetchJob($backgroundQueue, $b->getId())), 'dokončený A není překážka'); + } + + /** + * Test 4 - zámek na skupinu (advisory lock). Plný race se single-process nedá reprodukovat, + * ověřujeme jen, že zámkové primitivum funguje a serializuje pouze v rámci jedné skupiny. + * + * @see docs/priority-serialgroup.md "Test 4 - zámek na skupinu (bod 3)" + * + * @throws ReflectionException + * @throws Exception + */ + public function testGroupLockPrimitive() + { + $reflectionClass = new \ReflectionClass(BackgroundQueue::class); + + // Před opravou (bod 3) metody ještě neexistují - test má spadnout na tomto assertu, ne na chybě reflexe. + $this->tester->assertTrue($reflectionClass->hasMethod('acquireGroupLock'), 'acquireGroupLock zatím není implementováno'); + $this->tester->assertTrue($reflectionClass->hasMethod('releaseGroupLock'), 'releaseGroupLock zatím není implementováno'); + + $acquire = $reflectionClass->getMethod('acquireGroupLock'); + $acquire->setAccessible(true); + $release = $reflectionClass->getMethod('releaseGroupLock'); + $release->setAccessible(true); + + // Dvě samostatné connection (jako processJob přes createConnection) + $bq1 = self::getBackgroundQueue(); + $bq2 = self::getBackgroundQueue(); + + // Stejná skupina: druhý zámek se nezíská, dokud první nepustí + $acquire->invoke($bq1, 'skupina'); + $this->assertThrows(\RuntimeException::class, function () use ($acquire, $bq2) { + $acquire->invoke($bq2, 'skupina'); + }); + $release->invoke($bq1, 'skupina'); + $acquire->invoke($bq2, 'skupina'); // po uvolnění už projde + $release->invoke($bq2, 'skupina'); + + // Různé skupiny se navzájem neblokují + $acquire->invoke($bq1, 'skupinaA'); + $acquire->invoke($bq2, 'skupinaB'); + $release->invoke($bq1, 'skupinaA'); + $release->invoke($bq2, 'skupinaB'); + + $this->tester->assertTrue(true, 'různé skupiny se neblokují'); + } + + /** + * Test 5 - podmíněný claim v save(): job, který mezitím změnil stav (RabbitMQ redelivery), + * se nesmí zpracovat podruhé. + * + * @see docs/priority-serialgroup.md "Test 5 - podmíněný claim v save() (bod 4)" + * + * @throws Exception + */ + public function testConditionalClaimInSave() + { + $backgroundQueue = self::getBackgroundQueue(); + + // Část 1: job byl "sebrán jiným konzumentem" (v DB už není ready) -> nesmí se zpracovat + $backgroundQueue->publish('processRecording', ['blocked']); + $blockedId = self::fetchAllJobs($backgroundQueue)[0]->getId(); + self::rawConnection()->update($_ENV['PROJECT_DB_TABLENAME'], ['state' => BackgroundJob::STATE_FINISHED], ['id' => $blockedId]); + + $backgroundQueue->processJob($blockedId); + + $this->tester->assertEquals([], Mailer::$processOrder, 'callback se nesmí spustit'); + $this->tester->assertEquals(BackgroundJob::STATE_FINISHED, self::fetchJob($backgroundQueue, $blockedId)->getState(), 'stav se nezměnil'); + + // Část 2 (negativní kontrola): běžný READY job projde standardní cestou + Mailer::$processOrder = []; + $backgroundQueue->publish('processRecording', ['ok']); + $okId = null; + foreach (self::fetchAllJobs($backgroundQueue) as $job) { + if ($job->getState() === BackgroundJob::STATE_READY) { + $okId = $job->getId(); + } + } + $backgroundQueue->processJob($okId); + + $this->tester->assertEquals(['ok'], Mailer::$processOrder, 'běžný job se zpracuje'); + $this->tester->assertEquals(BackgroundJob::STATE_FINISHED, self::fetchJob($backgroundQueue, $okId)->getState()); + } + + /** + * Test 6 - komplexní broker-mode end-to-end přes celou smyčku + * process() -> prioritní fronty -> consume -> checkUnfinishedJobs -> WAITING -> _processWaitingJobs. + * + * @see docs/priority-serialgroup.md "Test 6 - komplexní broker-mode end-to-end (celá smyčka)" + * + * @throws Exception + */ + public function testBrokerModeEndToEnd() + { + $backgroundQueue = self::getBackgroundQueue(true, false, false, [1, 2]); + + Mailer::$connection = self::rawConnection(); + Mailer::$tableName = $_ENV['PROJECT_DB_TABLENAME']; + + $jobs = [['a', 1], ['b', 1], ['c', 2], ['d', 1], ['e', 1], ['f', 2], ['g', 1]]; + foreach ($jobs as [$mark, $priority]) { + $backgroundQueue->publish('processRecording', [$mark], 'transcribe', null, ModeEnum::NORMAL, null, $priority); + } + + // Bootstrap interního _processWaitingJobs (registruje se jen v broker módu přes process()). + $backgroundQueue->process(); + + // Konzumace v pořadí priorit + zpracování každého ID, dokud nejsou všechny pracovní joby hotové. + $maxSteps = 60; + while ($maxSteps-- > 0) { + $id = self::getProducer()->consume(); + if ($id === null) { + break; + } + $backgroundQueue->processJob((int) $id); + if (self::finishedCount('transcribe') === count($jobs)) { + break; + } + } + + $this->tester->assertEquals(['a', 'b', 'd', 'e', 'g', 'c', 'f'], Mailer::$processOrder, 'pořadí dle priority přes broker cestu'); + $this->tester->assertFalse(Mailer::$serialGroupViolation, 'sériovost nesmí být porušena'); + } + + private static function finishedCount(string $serialGroup): int + { + return (int) self::rawConnection()->fetchOne( + 'SELECT COUNT(*) FROM ' . $_ENV['PROJECT_DB_TABLENAME'] . ' WHERE serial_group = ? AND state = ?', + [$serialGroup, BackgroundJob::STATE_FINISHED] + ); + } + + private static function fetchJob(BackgroundQueue $backgroundQueue, int $id): BackgroundJob + { + foreach (self::fetchAllJobs($backgroundQueue) as $job) { + if ($job->getId() === $id) { + return $job; + } + } + throw new Exception('Job ' . $id . ' not found.'); + } + private static function getProducer(): Producer { if (!self::$producer) { @@ -275,7 +546,7 @@ private static function fetchAllJobs(BackgroundQueue $backgroundQueue): array /** * @throws \Doctrine\DBAL\Exception */ - private static function getBackgroundQueue(bool $producer = false, bool $waitingQueue = false, bool $logger = false): BackgroundQueue + private static function getBackgroundQueue(bool $producer = false, bool $waitingQueue = false, bool $logger = false, array $priorities = [1]): BackgroundQueue { $bq = new BackgroundQueue([ 'callbacks' => [ @@ -284,13 +555,15 @@ private static function getBackgroundQueue(bool $producer = false, bool $waiting 'processWithPermanentError' => [new Mailer(), 'processWithPermanentError'], 'processWithWaitingException' => [new Mailer(), 'processWithWaitingException'], 'processWithTypeError' => [new Mailer(), 'processWithTypeError'], - 'processWithOnErrorException' => [new Mailer(), 'processWithOnErrorException'] + 'processWithOnErrorException' => [new Mailer(), 'processWithOnErrorException'], + 'processRecording' => [new Mailer(), 'processRecording'] ], 'notifyOnNumberOfAttempts' => 5, 'tempDir' => $_ENV['PROJECT_TMP_FOLDER'], 'connection' => DriverManager::getConnection(BackgroundQueue::parseDsn(self::getDsn())), 'queue' => 'general', 'tableName' => $_ENV['PROJECT_DB_TABLENAME'], + 'priorities' => $priorities, 'producer' => $producer ? self::getProducer() : null, 'waitingQueue' => $waitingQueue ? 'waiting' : null, 'waitingJobExpiration' => 1000, @@ -305,6 +578,11 @@ private static function getBackgroundQueue(bool $producer = false, bool $waiting return $bq; } + private static function rawConnection(): \Doctrine\DBAL\Connection + { + return DriverManager::getConnection(BackgroundQueue::parseDsn(self::getDsn())); + } + private static function getDsn() { return 'mysql://' . $_ENV['PROJECT_DB_USER'] . ':' . $_ENV['PROJECT_DB_PASSWORD'] . '@' . $_ENV['PROJECT_DB_HOST'] . ':' . $_ENV['PROJECT_DB_PORT'] . '/' . $_ENV['PROJECT_DB_DBNAME']; @@ -319,7 +597,11 @@ private static function clear() $connection->executeStatement('DROP TABLE IF EXISTS ' . $_ENV['PROJECT_DB_TABLENAME']); $connection->executeStatement('SET FOREIGN_KEY_CHECKS=1;'); + // Mažeme i prioritní podfronty, protože zprávy teď chodí do "general_". self::getProducer()->purge('general'); + self::getProducer()->purge('general_0'); + self::getProducer()->purge('general_1'); + self::getProducer()->purge('general_2'); self::getProducer()->purge('waiting'); @rmdir($_ENV['PROJECT_TMP_FOLDER'] . '/background_queue_schema_generated'); diff --git a/tests/Support/Helper/Mailer.php b/tests/Support/Helper/Mailer.php index 840029a..d6e2ab9 100644 --- a/tests/Support/Helper/Mailer.php +++ b/tests/Support/Helper/Mailer.php @@ -2,12 +2,50 @@ namespace Tests\Support\Helper; +use ADT\BackgroundQueue\Entity\BackgroundJob; use ADT\BackgroundQueue\Exception\PermanentErrorException; use ADT\BackgroundQueue\Exception\WaitingException; +use Doctrine\DBAL\Connection; use Exception; +/** + * Testovací atrapa "zpracovatele" jobu - její metody se registrují jako callbacky do BackgroundQueue. + * S e-maily nijak nesouvisí; název je jen ilustrativní (odeslání e-mailu = typický job na pozadí). + * Jednotlivé metody simulují různé výsledky zpracování (úspěch, dočasná/trvalá chyba, ...). + */ class Mailer { + /** + * Pořadí, ve kterém byly joby skutečně zpracovány. Značku (typicky identifikátor jobu) + * dostane callback {@see self::processRecording()} jako parametr a připíše ji sem. + * Slouží testům priority/serialGroup k ověření výsledného pořadí zpracování. + * + * @var string[] + */ + public static array $processOrder = []; + + /** + * Spojení a název tabulky pro kontrolu sériovosti přímo uvnitř callbacku. + * Pokud jsou nastaveny, {@see self::processRecording()} ověří, že v daný okamžik + * neběží (není ve stavu PROCESSING) víc jobů jedné serialGroup současně. + */ + public static ?Connection $connection = null; + public static ?string $tableName = null; + + /** Nastaví se na true, pokud kdykoli během zpracování běžely dva joby stejné serialGroup naráz. */ + public static bool $serialGroupViolation = false; + + /** + * Vynuluje sdílený stav mezi testy. + */ + public static function reset(): void + { + self::$processOrder = []; + self::$connection = null; + self::$tableName = null; + self::$serialGroupViolation = false; + } + public function process(): void { @@ -37,4 +75,24 @@ public function processWithOnErrorException(): void { throw new OnErrorException(); } -} \ No newline at end of file + + /** + * Zaznamená pořadí zpracování (značka = parametr) a zkontroluje sériovost. + */ + public function processRecording(string $mark): void + { + self::$processOrder[] = $mark; + + // Sériovost: v jednu chvíli smí být ve stavu PROCESSING max. jeden job se serialGroup. + // Aktuální job už je při běhu callbacku zapsaný jako PROCESSING, takže korektní stav = 1. + if (self::$connection && self::$tableName) { + $processingInGroups = (int) self::$connection->fetchOne( + 'SELECT COUNT(*) FROM ' . self::$tableName . ' WHERE serial_group IS NOT NULL AND state = ?', + [BackgroundJob::STATE_PROCESSING] + ); + if ($processingInGroups > 1) { + self::$serialGroupViolation = true; + } + } + } +} diff --git a/tests/Support/Helper/Producer.php b/tests/Support/Helper/Producer.php index 9b50ac9..d3c1d4c 100644 --- a/tests/Support/Helper/Producer.php +++ b/tests/Support/Helper/Producer.php @@ -13,11 +13,17 @@ class Producer implements \ADT\BackgroundQueue\Broker\Producer public ?AMQPChannel $channel = null; private array $initQueues = []; + /** + * Priorita se v RabbitMQ modeluje jako samostatná fronta "_" + * (viz reálný Producer / Manager::getQueueWithPriority). Helper to napodobuje, + * aby šel otestovat prioritní routing i konzumace v pořadí priorit. + */ public function publish(string $id, string $queue, int $priority, ?int $expiration = null): void { - $this->initQueue($queue); + $queueWithPriority = $queue . '_' . $priority; + $this->initQueue($queueWithPriority); - $this->getChannel()->basic_publish(new AMQPMessage($id, $expiration ? ['expiration' => $expiration] : []), $queue, '', true); + $this->getChannel()->basic_publish(new AMQPMessage($id, $expiration ? ['expiration' => $expiration] : []), $queueWithPriority, '', true); $this->getChannel()->wait_for_pending_acks(); } @@ -31,9 +37,21 @@ public function publishNoop(): void } - public function consume() + /** + * Zkonzumuje jednu zprávu z prioritních front základní fronty "general" + * v pořadí priorit (nižší číslo dřív), stejně jako reálný Consumer. + * Vrátí tělo zprávy (ID jobu) nebo null, pokud žádná není k dispozici. + */ + public function consume(?string $queue = null): ?string { - $this->getChannel()->basic_get('general', true); + foreach ($this->getSortedPriorityQueues($queue ?? 'general') as $priorityQueue) { + $message = $this->getChannel()->basic_get($priorityQueue, true); + if ($message !== null) { + return $message->getBody(); + } + } + + return null; } public function purge(string $queue): void @@ -42,11 +60,39 @@ public function purge(string $queue): void $this->getChannel()->queue_purge($queue); } - public function getMessageCount(string $queue) + /** + * Vrátí počet zpráv ve frontě. Pokud je zadána základní fronta (např. "general"), + * sečte všechny její prioritní podfronty ("general_1", "general_2", ...). + */ + public function getMessageCount(string $queue): int { - list(, $messageCount,) = $this->getChannel()->queue_declare($queue, true); + $total = 0; + foreach (array_keys($this->initQueues) as $declared) { + if ($declared === $queue || str_starts_with($declared, $queue . '_')) { + list(, $messageCount,) = $this->getChannel()->queue_declare($declared, true); + $total += (int) $messageCount; + } + } + + return $total; + } + + /** + * Seřadí dosud inicializované prioritní podfronty dané základní fronty vzestupně podle priority. + * + * @return string[] + */ + private function getSortedPriorityQueues(string $base): array + { + $queues = []; + foreach (array_keys($this->initQueues) as $declared) { + if (preg_match('/^' . preg_quote($base, '/') . '_(\d+)$/', $declared, $matches)) { + $queues[(int) $matches[1]] = $declared; + } + } + ksort($queues); - return $messageCount; + return array_values($queues); } private function initQueue($queue) @@ -92,4 +138,4 @@ public function __destruct() $this->channel->close(); $this->connection->close(); } -} \ No newline at end of file +} From 7e353b334c032100d84c0eabdc6aa6463b4663cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Ma=C5=A1=C3=AD=C4=8Dek?= Date: Fri, 19 Jun 2026 18:36:33 +0200 Subject: [PATCH 08/15] Optimize findOldestUnfinishedJobIdsByGroup + harden serialGroup advisory lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Priority-aware head selection uses a JOIN with a derived table instead of a DEPENDENT SUBQUERY, eliminating O(W²) complexity and removing the need for a composite index. On top of that, the serialGroup advisory lock is hardened so it works correctly on both MySQL and PostgreSQL and never aborts a whole process() run / consumer on lock contention. Head selection / query changes: - findOldestUnfinishedJobIdsByGroup: rewrite to JOIN-based approach (O(W) linear, MySQL+PostgreSQL compatible) - Test 7: add deterministic regression test for head-selection logic with a priority-gap scenario - docs/priority-serialgroup.md: add performance analysis with benchmark table (1k-20k rows: ~160x-1100x speedup), compare variants (correlated subquery vs. derived table JOIN vs. composite index), explain why variant B was chosen (zero write-amplification, no index needed) - Test 2b: add sleep between process() calls to respect WAITING job postponement - Producer helper: implement delayed-message recirculation model (in-memory TTL buffer) to match real Producer behavior and prevent livelock on the highest-priority _processWaitingJobs Advisory lock hardening: - PostgreSQL: use single-arg pg_advisory_lock(bigint) with a composed 64-bit key ((namespace << 32) | crc32). The two-arg int4 variant would overflow on crc32 values > 2^31 ("integer out of range") - MySQL: hash serial_group via crc32 into the lock name. GET_LOCK names are capped at 64 chars and serial_group is VARCHAR(255), so a long group name would make GET_LOCK return NULL and silently fail - acquireGroupLock now returns bool instead of throwing. On failure processJob re-publishes the job to the broker and returns, so a single lock collision no longer aborts the whole process() run / consumer (the job is retried next time; in cron mode it is picked up by the next process() run) - Test 4: update to the new bool contract; docs updated incl. crc32 collision probability estimate (birthday problem over 2^32) All tests passing (26 tests, 71 assertions). No new indexes added - avoids write-amplification from frequent state changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/priority-serialgroup.md | 192 +++++++++++--- src/BackgroundQueue.php | 295 +++++++++++++++++----- tests/Integration/BackgroundQueueTest.php | 77 +++++- tests/Support/Helper/Producer.php | 70 ++++- 4 files changed, 523 insertions(+), 111 deletions(-) diff --git a/docs/priority-serialgroup.md b/docs/priority-serialgroup.md index 2c20895..578a36d 100644 --- a/docs/priority-serialgroup.md +++ b/docs/priority-serialgroup.md @@ -176,7 +176,7 @@ nejdřív `checkUnfinishedJobs()` (řádek 254), pak až o kus dál `setState(PR současně. Řešení: kolem **[checkUnfinishedJobs + zápis PROCESSING]** dát pojmenovaný zámek na -skupinu - MySQL `GET_LOCK('bgq:'+serialGroup)` na začátku, `RELEASE_LOCK` po zápisu. +skupinu - MySQL `GET_LOCK('bgq:'+crc32(serialGroup))` na začátku, `RELEASE_LOCK` po zápisu. Drží se jen pár mikrosekund (ne po dobu zpracování callbacku). `processJob` má vlastní `createConnection()` (řádek 235), takže connection-scoped zámek sedí. Serializuje jen konzumenty téže skupiny, různé skupiny se neperou. @@ -195,25 +195,30 @@ use Doctrine\DBAL\Platforms\PostgreSQLPlatform; $platform = $this->connection->getDatabasePlatform(); ``` +`acquireGroupLock` vrací `bool` (true = získáno, false = v limitu nezískáno) - **nevyhazuje +výjimku**. Když se zámek nezíská, `processJob` job nezahodí: vrátí ho do brokera +(`publishToBroker`) a zpracuje se příště (v cron módu je to no-op a job vezme příští běh +`process()`). Tím jediná kolize zámku neshodí výjimkou celý běh `process()` / konzumenta. + MySQL (`GET_LOCK` / `RELEASE_LOCK`): ```php -private function acquireGroupLock(string $serialGroup): void +private function acquireGroupLock(string $serialGroup): bool { + // Název zámku musí být max 64 znaků (jinak GET_LOCK vrátí NULL); serial_group je + // VARCHAR(255), proto skupinu hashujeme přes crc32 na stabilní krátký název. $acquired = $this->connection->fetchOne( - "SELECT GET_LOCK(?, 10)", - ['bgq:' . $serialGroup] + "SELECT GET_LOCK(?, ?)", + ['bgq:' . crc32($serialGroup), self::GROUP_LOCK_TIMEOUT] ); - if (!$acquired) { - throw new \RuntimeException('Could not acquire serial group lock for: ' . $serialGroup); - } + return (int) $acquired === 1; // 1 = získáno, 0 = timeout, NULL = chyba } private function releaseGroupLock(string $serialGroup): void { $this->connection->executeStatement( "SELECT RELEASE_LOCK(?)", - ['bgq:' . $serialGroup] + ['bgq:' . crc32($serialGroup)] ); } ``` @@ -221,32 +226,55 @@ private function releaseGroupLock(string $serialGroup): void PostgreSQL (`pg_advisory_lock` / `pg_advisory_unlock`): ```php -private function acquireGroupLock(string $serialGroup): void +private function acquireGroupLock(string $serialGroup): bool { - // pg_advisory_lock vyžaduje integer klíče - použijeme fixní namespace + crc32 skupiny. - // Timeout přes lock_timeout session proměnnou (platí jen pro toto volání). - $this->connection->executeStatement("SET LOCAL lock_timeout = '10s'"); - $this->connection->executeStatement( - "SELECT pg_advisory_lock(?, ?)", - [self::PG_LOCK_NAMESPACE, crc32($serialGroup)] - ); + // pg_advisory_lock(bigint): klíč = (namespace << 32) | crc32(skupina). Dvouargumentová + // varianta bere int4 a crc32 (až 2^32-1) by ji přetekla ("integer out of range"), proto + // jednoargumentová bigint varianta se složeným 64bit klíčem. Timeout přes lock_timeout. + $this->connection->executeStatement("SET lock_timeout = '" . (self::GROUP_LOCK_TIMEOUT * 1000) . "'"); + try { + $this->connection->executeStatement( + "SELECT pg_advisory_lock(?)", + [(self::PG_LOCK_NAMESPACE << 32) | crc32($serialGroup)] + ); + } catch (\Doctrine\DBAL\Exception $e) { + return false; // vypršení lock_timeout - job zpracujeme příště + } + return true; } private function releaseGroupLock(string $serialGroup): void { $this->connection->executeStatement( - "SELECT pg_advisory_unlock(?, ?)", - [self::PG_LOCK_NAMESPACE, crc32($serialGroup)] + "SELECT pg_advisory_unlock(?)", + [(self::PG_LOCK_NAMESPACE << 32) | crc32($serialGroup)] ); } ``` -Konstanta `PG_LOCK_NAMESPACE` (např. `0x42475100` = ASCII `BGQ\0`) odděluje klíče -background-queue od ostatních advisory locků v aplikaci. `crc32($serialGroup)` vrací -int32 - pro jména skupin v rámci jedné aplikace je pravděpodobnost kolize zanedbatelná; -při potřebě vyšší jistoty lze použít `abs(crc32(...))` nebo dedikovanou číselnou řadu. - -`processJob` pak volá `acquireGroupLock` / `releaseGroupLock` beze znalosti DB platformy +Konstanta `PG_LOCK_NAMESPACE` (`0x42475100` = ASCII `BGQ\0`) tvoří horní 32 bitů klíče a +odděluje zámky background-queue od ostatních advisory locků v aplikaci; dolních 32 bitů je +`crc32($serialGroup)`. Složený klíč se vejde do signed bigint (`namespace << 32` ≈ 4,8e18 < +bigint max ~9,2e18). MySQL i PostgreSQL tak mapují skupinu na `crc32`, takže kolize nastane +jen při kolizi crc32 dvou různých názvů skupin - viz odhad pravděpodobnosti níže. Kolize +neohrozí korektnost, jen by dvě nezávislé skupiny zbytečně serializovala. + +**Pravděpodobnost kolize crc32 (birthday problem, prostor 2^32).** Pro `n` současně živých +různých názvů skupin je šance, že aspoň dvě sdílí crc32, ≈ `1 - e^(-n²/2^33)`: + +| různých skupin `n` | P(aspoň jedna kolize) | +|-------------------:|----------------------:| +| 100 | ~0,0001 % (1 z ~860 tis.) | +| 1 000 | ~0,012 % (1 z ~8 600) | +| 10 000 | ~1,15 % | +| 50 000 | ~25 % | +| 77 000 | ~50 % | + +V praxi je počet *současně živých* serialGroup řádově malý (jednotky až stovky), takže je +kolize zanedbatelná. Kdyby se počet skupin blížil desítkám tisíc, šlo by přejít na 64bit +hash (např. `crc32b` + `crc32` do dvou půlek, nebo `substr(md5(...), 0, 16)` na hex → int). + +`processJob` volá `acquireGroupLock` / `releaseGroupLock` beze znalosti DB platformy a switch logika žije pouze v těchto dvou metodách. Příklad kolize bez zámku (A má horší prioritu, B přijde dřív než A zapsal PROCESSING): @@ -321,18 +349,80 @@ zpracovává nebo dokončil) → aktuální konzument tiše vrátí `return`. Ch Jde o **laciné pojistné patro navíc**: UPDATE je atomický na úrovni DB, takže i při dokonalém souběhu T3 a T4 může podmíněný UPDATE uspět jen jednomu z konzumentů. -### Náročnost / indexy - -**Souhrn:** Problém s náročností při velkém počtu nedokončených jobů existuje už dnes - -nevzniká novým řešením. Nová podmínka (`priority`, `PROCESSING`) na tom nic nemění, protože -jde o filtry vyhodnocené na řádcích, které už beztak vybral index `state`. - -Přidané podmínky (`priority`, `PROCESSING`) jsou residual filtry na řádcích, které už -vybral index `state` (`src/BackgroundQueue.php:457-459` zakládá indexy jen na `id`, `identifier`, -`state`). **Žádný nový index nepotřebují, plán dotazu se nemění.** Náklad je daný počtem -nedokončených jobů, ne velikostí tabulky. (Pokud by se nedokončené joby hromadily do -velkých čísel, šlo by zvážit kompozitní index `(serial_group, state, priority, id)`, ale -to je nezávislé na této opravě.) +### Náročnost, výběr hlavy skupiny a benchmark + +Náročnost je třeba posoudit zvlášť pro dva dotazy, které řazení dle `(priorita, ID)` +přepracovává. + +**a) `getPreviousUnfinishedJobId` (hot path - volá se pro každý zpracovávaný job).** +Přidané podmínky (`priority`, `PROCESSING`) jsou jen residual filtry na řádcích, které už +vybral index `state` (`updateSchema()` zakládá indexy na `id`, `identifier`, `state`). +Dotaz je navíc omezen na jednu `serial_group` a stačí mu existence prvního předchůdce, proto +má `LIMIT 1` a vrací max jeden řádek. Žádný nový index nepotřebuje. + +**b) `findOldestUnfinishedJobIdsByGroup` (volá `processWaitingJobs`, v broker módu zhruba +jednou za `waitingJobExpiration`, tj. ~1×/s).** Tady "hlava skupiny" už není prosté +`MIN(id)` jako ve staré implementaci (řazení jen dle ID), ale "nejmenší ID z nejvyšší +přítomné priority ve skupině". To vyžaduje agregaci přes `(priorita, ID)` a právě tady +vznikl problém s náročností. Níže zvažované varianty řeší jen tenhle dotaz. + +#### Zvažované varianty + +- **Stará implementace (před prioritou):** `SELECT MIN(id) ... GROUP BY serial_group`. Jeden + grupovaný průchod, ~O(W) (W = počet nedokončených jobů ve frontě). Funguje ale jen pro + řazení čistě podle ID, prioritu vyjádřit neumí. +- **Mezikrok (zavržený) - dedup v PHP:** vytáhnout všechny řádky seřazené dle `(priorita, ID)` + a v PHP nechat první na skupinu. Jednoduché, ale do PHP táhne **všechny** WAITING joby + napříč skupinami - horší než stará implementace. Zamítnuto. +- **Korelovaný poddotaz:** `... AND priority = (SELECT MIN(sub.priority) ... WHERE + sub.serial_group = ...)`. Agregace zpět v SQL (jeden řádek na skupinu), ale `EXPLAIN` + ukázal `DEPENDENT SUBQUERY` - poddotaz se vyhodnocuje pro **každý vnější řádek**, tedy + **O(W²)**. Bez kompozitního indexu na velkém objemu extrémně pomalé (viz benchmark). +- **Varianta A - kompozitní index `(serial_group, state, priority, id)`:** korelovaný + poddotaz se s ním změní z plného skenu na `ref` lookup, tedy zpět k ~lineárnímu. Nevýhody: + `state` je v indexu, takže každá z mnoha změn stavu jobu navíc udržuje tenhle (široký, + kvůli `serial_group VARCHAR(255)` má klíč ~1 KB) index = write-amplifikace; index pokrývá + i řádky bez serialGroup (`serial_group IS NULL`) a pomáhá jen dvěma dotazům. +- **Varianta B (zvolená) - JOIN s odvozenou tabulkou:** nejnižší priorita každé skupiny se + spočítá **jednou** v odvozené tabulce a připojí se zpět: + + ```sql + SELECT MIN(t.id) AS id + FROM background_job t + INNER JOIN ( + SELECT serial_group, MIN(priority) AS min_priority + FROM background_job + WHERE queue LIKE ... AND state IN (...) + GROUP BY serial_group + ) head ON head.serial_group = t.serial_group AND t.priority = head.min_priority + WHERE t.queue LIKE ... AND t.state IN (...) + GROUP BY t.serial_group + ORDER BY id ASC + ``` + + ~O(W) **nezávisle na indexu** - žádný nový index, žádná daň na zápisech. + +#### Benchmark (MySQL 8, ~20 jobů na skupinu, jen index na `state`, minimum ze 3 běhů) + +| řádků (W) | skupin | korelovaný poddotaz | odvozená tabulka (B) | zrychlení | +|----------:|-------:|--------------------:|---------------------:|----------:| +| 1 000 | 50 | 289 ms | 1,8 ms | ~160× | +| 2 000 | 100 | 1 124 ms | 3,3 ms | ~340× | +| 4 000 | 200 | 4 480 ms | 8,8 ms | ~510× | +| 8 000 | 400 | 17 893 ms | 16,5 ms | ~1080× | +| 20 000 | 1 000 | ~112 000 ms* | 100 ms | ~1100× | + +\* korelovaný na 20k nedoměřen do konce (běžel by ~2 min/běh); hodnota je extrapolace z čisté +O(W²) řady (každé zdvojnásobení W ≈ 4× čas). Výstupy obou variant jsou bitově shodné +(ověřeno md5 i regresním testem, viz Test 7 níže). + +**Závěr:** Korelovaný poddotaz roste kvadraticky a při ~1×/s cadenci v broker módu je na 8k+ +nedokončených jobech neudržitelný (18 s/běh). Varianta B roste prakticky lineárně a zůstává +v jednotkách až ~100 ms i na 20k, bez nutnosti indexu (a tedy bez write-amplifikace na časté +změny stavu, kterou by přinesla varianta A). Proto je v +`findOldestUnfinishedJobIdsByGroup` zvolena **varianta B**; hot-path `getPreviousUnfinishedJobId` +řeší množství přes `LIMIT 1`. Kompozitní index (A) tím není potřeba - pokud by se v budoucnu +hodil pro jiné dotazy, je to nezávislé rozhodnutí. ### Vlastnosti @@ -468,11 +558,11 @@ na platformě (poběží proti MySQL z CI): - Vytvořit **dvě samostatné** DBAL connection ze stejného DSN (jako `processJob` přes `createConnection()`, `src/BackgroundQueue.php:235`). -- Na connection č. 1 `acquireGroupLock('skupina')` → uspěje. -- Na connection č. 2 `acquireGroupLock('skupina')` s krátkým timeoutem → **neuspěje / - zablokuje se** (u MySQL `GET_LOCK(..., timeout)` vrátí 0 → metoda vyhodí `RuntimeException`). +- Na connection č. 1 `acquireGroupLock('skupina')` → vrátí `true` (uspěje). +- Na connection č. 2 `acquireGroupLock('skupina')` s krátkým timeoutem → vrátí `false` + (u MySQL `GET_LOCK(..., timeout)` vrátí 0; metoda nevyhazuje výjimku, jen vrátí `false`). - Na connection č. 1 `releaseGroupLock('skupina')`. -- Na connection č. 2 `acquireGroupLock('skupina')` → nyní **uspěje**. +- Na connection č. 2 `acquireGroupLock('skupina')` → nyní vrátí `true` (uspěje). - Druhý případ: dvě **různé** skupiny se navzájem neblokují (obě connection získají zámek každá pro svou skupinu) - ověří, že serializujeme jen v rámci jedné skupiny. @@ -529,6 +619,27 @@ Pozn.: test vyžaduje běžící RabbitMQ (běží v CI kontejneru `background-q je z celé sady nejdražší a nejcitlivější na časování; proto zůstává jediný svého druhu - konkrétní větve levněji a stabilněji pokrývají jednotkové testy 2a/3/5. +### Test 7 - výběr hlavy skupiny dle (priorita, ID) v no-entity větvi + +Doplňkový, čistě deterministický test pro `findOldestUnfinishedJobIdsByGroup()` a navazující +`processWaitingJobs()`. Happy-path Test 6 tuhle větev s daty neprověří (po opravě řazení se +v něm do WAITING nic nedostane, takže `processWaitingJobs` jede naprázdno), proto ji testujeme +cíleně - včetně skupiny s "dírou" v prioritách (žádný job na nejvyšší prioritě). Zároveň +regresně hlídá implementaci výběru hlavy přes odvozenou tabulku (viz kapitola „Náročnost, +výběr hlavy skupiny a benchmark"). + +- Konfigurace `priorities` `[1, 2, 3]`, cron mód (bez produceru), callback `processRecording`. +- Skupina `gA`: joby s prioritami 2, 1, 1 → hlava je `a2` (nejmenší ID v nejvyšší přítomné + prioritě 1). +- Skupina `gB` s dírou: joby jen s prioritami 3 a 2 (žádná 1) → hlava je `b2` (priorita 2), + ne `b1`. Ověřuje, že "nejvyšší priorita" znamená nejmenší **přítomnou** prioritu, ne pevnou + hodnotu. +- Všechny joby ručně přepnout na `STATE_WAITING`, pak reflexí: + 1. `findOldestUnfinishedJobIdsByGroup(STATE_WAITING)` → vrátí hlavy `[a2, b2]`. + 2. `processWaitingJobs()` → přepne právě tyhle hlavy na `READY`, zbytek zůstane `WAITING`. +- Před starou logikou (`MIN(id)` bez priority) by hlavy vyšly `a1`/`b1` → test červeně chytí + regresi. + ### Shrnutí pokrytí | Test | Pokrývá | Charakter | @@ -539,3 +650,4 @@ konkrétní větve levněji a stabilněji pokrývají jednotkové testy 2a/3/5. | 4 | bod 3 (zámek na skupinu) | jen primitivum (race nereprodukovatelný single-process) | | 5 | bod 4 (podmíněný claim) | reprodukce redelivery race | | 6 | celá broker smyčka + `_processWaitingJobs` | end-to-end, vyžaduje RabbitMQ, nejdražší | +| 7 | výběr hlavy skupiny dle (priorita, ID), `findOldestUnfinishedJobIdsByGroup`/`processWaitingJobs` | deterministická reprodukce, červený → zelený | diff --git a/src/BackgroundQueue.php b/src/BackgroundQueue.php index a8cca31..41d1ea8 100644 --- a/src/BackgroundQueue.php +++ b/src/BackgroundQueue.php @@ -14,8 +14,10 @@ use DateTime; use DateTimeImmutable; use DateTimeInterface; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Query\QueryBuilder; use Doctrine\DBAL\Schema\AbstractSchemaManager; use Doctrine\DBAL\Schema\Schema; @@ -35,6 +37,13 @@ class BackgroundQueue { const UNEXPECTED_ERROR_MESSAGE = 'Unexpected error occurred.'; + // Namespace pro PostgreSQL advisory locky (ASCII "BGQ\0") - odděluje naše zámky od ostatních v aplikaci. + private const PG_LOCK_NAMESPACE = 0x42475100; + + // Jak dlouho (v sekundách) konzument čeká na zámek skupiny, než zpracování vzdá. + // Kritická sekce [check + zápis PROCESSING] trvá jen pár mikrosekund, takže tato hodnota je strop pro souběh, ne pro práci. + private const GROUP_LOCK_TIMEOUT = 3; + private array $config; private bool $connectionCreated = false; private Connection $connection; @@ -78,6 +87,10 @@ public function __construct(array $config) $config['bulkSize'] = 1; } if ($config['producer']) { + // _processWaitingJobs běží na nejvyšší prioritě (null => první priorita v seznamu), aby obnova WAITING jobů + // nikdy nehladověla - jinak by při trvalém zatížení vyšších priorit serial groupy uvízly. Jeho nekonečné + // opakování nehladoví nižší priority díky zpoždění recirkulace: cloneAndPublish ho přepublikuje s + // postponeBy = waitingJobExpiration, takže se v nejvyšší frontě objevuje jen periodicky, ne nepřetržitě. $config['callbacks'][CallbackNameEnum::PROCESS_WAITING_JOBS->value] = [ 'callback' => [$this, trim(CallbackNameEnum::PROCESS_WAITING_JOBS->value, '_')], 'queue' => null, @@ -251,40 +264,63 @@ public function processJob(int $id): void return; } - if (!$this->checkUnfinishedJobs($entity)) { + // Kritickou sekci [checkUnfinishedJobs + zápis PROCESSING] serializujeme zámkem na skupinu, + // aby dva konzumenti téže serialGroup neprošli kontrolou současně. Bere se jen je-li serialGroup nastavena; + // joby bez skupiny se neserializují. Zámek se drží jen po dobu sekce, ne po dobu běhu callbacku. + $serialGroup = $entity->getSerialGroup(); + if ($serialGroup && !$this->acquireGroupLock($serialGroup)) { + // Zámek skupiny se nepodařilo získat v limitu (kritickou sekci právě drží jiný konzument téže skupiny). + // Job nezahazujeme - vrátíme ho do brokera, ať se zpracuje příště. V cron módu je publishToBroker no-op + // a job (stále v některém ze zpracovatelných stavů) vezme příští běh process(). Tím se vyhneme tomu, + // aby jediná kolize zámku shodila výjimkou celý běh process() / konzumenta. + $this->publishToBroker($entity); return; } - if (!isset($this->config['callbacks'][$entity->getCallbackName()])) { - $this->logException('Callback "' . $entity->getCallbackName() . '" does not exist.', $entity); - return; - } + $callback = null; + try { + if (!$this->checkUnfinishedJobs($entity)) { + return; + } - $callback = $this->getCallback($entity->getCallbackName()); + if (!isset($this->config['callbacks'][$entity->getCallbackName()])) { + $this->logException('Callback "' . $entity->getCallbackName() . '" does not exist.', $entity); + return; + } - if (!is_callable($callback)) { - $this->logException('Method "' . $callback[0] . '::' . $callback[1] . '" does not exist or is not callable.', $entity); - return; - } + $callback = $this->getCallback($entity->getCallbackName()); - // změna stavu na zpracovává se - try { - $entity->setState(BackgroundJob::STATE_PROCESSING); - $entity->setPostponedBy(null); - $entity->setErrorMessage(null); - $entity->updateLastAttemptAt(); - $entity->increaseNumberOfAttempts(); - $entity->updatePid(); - - if ($this->config['onProcessingGetMetadata']) { - $metadata = $this->config['onProcessingGetMetadata']($entity->getParameters()); - $entity->setMetadata($metadata); + if (!is_callable($callback)) { + $this->logException('Method "' . $callback[0] . '::' . $callback[1] . '" does not exist or is not callable.', $entity); + return; } - $this->save($entity); - } catch (Exception $e) { - $this->logException(self::UNEXPECTED_ERROR_MESSAGE, $entity, $e); - return; + // změna stavu na zpracovává se + try { + $entity->setState(BackgroundJob::STATE_PROCESSING); + $entity->setPostponedBy(null); + $entity->setErrorMessage(null); + $entity->updateLastAttemptAt(); + $entity->increaseNumberOfAttempts(); + $entity->updatePid(); + + if ($this->config['onProcessingGetMetadata']) { + $metadata = $this->config['onProcessingGetMetadata']($entity->getParameters()); + $entity->setMetadata($metadata); + } + + // Pokud claim neuspěl, job mezitím sebral nebo dokončil jiný konzument -> tiše končíme. + if (!$this->save($entity)) { + return; + } + } catch (Exception $e) { + $this->logException(self::UNEXPECTED_ERROR_MESSAGE, $entity, $e); + return; + } + } finally { + if ($serialGroup) { + $this->releaseGroupLock($serialGroup); + } } // zpracování callbacku @@ -576,43 +612,84 @@ private function isRedundant(BackgroundJob $entity): bool } /** + * Vrátí ID "předchůdce" daného jobu v jeho serialGroup, tj. jobu, který má jít před ním, jinak null. + * Předchůdce = cokoli ve skupině, co zrovna běží (PROCESSING - vzájemné vyloučení bez ohledu na prioritu), + * nebo job, který má jít dříve dle pořadí (vyšší priorita = nižší číslo, při shodě priority nižší ID). + * Stačí nám existence prvního takového jobu, proto LIMIT 1 (nevytahujeme celou skupinu). + * * @throws \Doctrine\DBAL\Exception - * @throws SchemaException */ private function getPreviousUnfinishedJobId(BackgroundJob $entity): ?int { - foreach ($this->findOldestUnfinishedJobIdsByGroup(array_merge(BackgroundJob::READY_TO_PROCESS_STATES, [BackgroundJob::STATE_PROCESSING]), $entity) as $id) { - return $id; - } - return null; + $states = array_values(array_merge(BackgroundJob::READY_TO_PROCESS_STATES, [BackgroundJob::STATE_PROCESSING])); + + // Klauzule na PROCESSING je povinná: bez ní by později vložený job s lepší prioritou + // nepoznal běžící nižší-prioritní job jako překážku a naběhl by souběžně (porušení sériovosti). + $id = $this->connection->createQueryBuilder() + ->select('id') + ->from($this->config['tableName']) + ->where('queue LIKE :queue') + ->andWhere('state IN (:states)') + ->andWhere('serial_group = :serialGroup') + ->andWhere('id <> :selfId') + ->andWhere('(state = :processingState OR priority < :priority OR (priority = :priority AND id < :selfId))') + ->orderBy('priority', 'ASC') + ->addOrderBy('id', 'ASC') + ->setParameter('queue', $this->config['queue'] . '%') + ->setParameter('states', $states, ArrayParameterType::INTEGER) + ->setParameter('serialGroup', $entity->getSerialGroup()) + ->setParameter('selfId', $entity->getId()) + ->setParameter('processingState', BackgroundJob::STATE_PROCESSING) + ->setParameter('priority', $entity->getPriority()) + ->setMaxResults(1) + ->executeQuery() + ->fetchOne(); + + return $id === false ? null : (int) $id; } /** - * @throws SchemaException + * Pro každou serialGroup s jobem v daném stavu vrátí ID její "hlavy" - jobu, který má jít první, + * tj. nejmenší ID mezi řádky s nejvyšší prioritou (nejnižší číslo) ve skupině. + * Agregaci děláme v SQL (jeden řádek na skupinu), ne v PHP, aby se nevytahovaly všechny nedokončené joby. + * + * Nejnižší prioritu každé skupiny spočítáme jednou v odvozené tabulce a tu pak připojíme - ne korelovaným + * poddotazem, který by se vyhodnocoval pro každý řádek zvlášť (O(W²) a bez kompozitního indexu na velkém + * počtu WAITING jobů extrémně pomalý). MIN(id) samo by prioritu ignorovalo a okenní funkce nejsou napříč + * MySQL/PostgreSQL jednotné. + * * @throws \Doctrine\DBAL\Exception */ - private function findOldestUnfinishedJobIdsByGroup(array|string $state, ?BackgroundJob $entity = null): iterable + private function findOldestUnfinishedJobIdsByGroup(array|string $state): iterable { - $qb = $this->createQueryBuilder(); - - $qb->select('MIN(id) as id'); - - $qb->andWhere('state IN (:state)') - ->setParameter('state', $state); - - if ($entity) { - $qb->andWhere('serial_group = :serialGroup') - ->setParameter('serialGroup', $entity->getSerialGroup()); - - $qb->andWhere('id < :id') - ->setParameter('id', $entity->getId()); - } - - $qb->groupBy('serial_group'); - - $qb->orderBy('id', 'ASC'); + $states = (array) $state; + $queueLike = $this->config['queue'] . '%'; + $table = $this->config['tableName']; - foreach ($this->fetchAll($qb, toEntity: false) as $row) { + $rows = $this->connection->createQueryBuilder() + ->select('MIN(t.id) AS id') + ->from($table, 't') + ->innerJoin( + 't', + "( + SELECT serial_group, MIN(priority) AS min_priority + FROM {$table} + WHERE queue LIKE :queue AND state IN (:states) + GROUP BY serial_group + )", + 'head', + 'head.serial_group = t.serial_group AND t.priority = head.min_priority' + ) + ->where('t.queue LIKE :queue') + ->andWhere('t.state IN (:states)') + ->groupBy('t.serial_group') + ->orderBy('id', 'ASC') + ->setParameter('queue', $queueLike) + ->setParameter('states', $states, ArrayParameterType::INTEGER) + ->executeQuery() + ->fetchAllAssociative(); + + foreach ($rows as $row) { yield $row['id']; } @@ -620,9 +697,12 @@ private function findOldestUnfinishedJobIdsByGroup(array|string $state, ?Backgro } /** + * Uloží job. Vrací false jen v případě podmíněného claimu (přechod do PROCESSING), + * kdy řádek mezitím sebral jiný konzument; ve všech ostatních případech vrací true. + * * @throws \Doctrine\DBAL\Exception */ - private function save(BackgroundJob $entity): void + private function save(BackgroundJob $entity): bool { $this->databaseConnectionCheckAndReconnect(); @@ -636,12 +716,113 @@ private function save(BackgroundJob $entity): void if (count($this->bulkDatabaseEntities) >= $this->bulkSize) { $this->doPublishToDatabase(); } - } else { - if ($entity->getState() === BackgroundJob::STATE_READY) { - $entity->setUpdatedAt(new DateTimeImmutable()); + + return true; + } + + if ($entity->getState() === BackgroundJob::STATE_READY) { + $entity->setUpdatedAt(new DateTimeImmutable()); + } + + // Podmíněný claim (atomické převzetí ke zpracování): přechod do PROCESSING smí uspět jen jednomu konzumentovi. + // Pokud řádek mezitím změnil stav (typicky RabbitMQ redelivery doručil totéž ID dvěma konzumentům), + // UPDATE neovlivní žádný řádek a job se nezpracuje podruhé. + if ($entity->getState() === BackgroundJob::STATE_PROCESSING) { + return $this->claimForProcessing($entity); + } + + $this->connection->update($this->config['tableName'], $entity->getDatabaseValues(), ['id' => $entity->getId()]); + + return true; + } + + /** + * Atomicky převezme job ke zpracování: nastaví ho na PROCESSING jen tehdy, je-li v DB stále + * v některém ze zpracovatelných stavů. Vrací true, pokud claim uspěl (UPDATE ovlivnil řádek). + * + * @throws \Doctrine\DBAL\Exception + */ + private function claimForProcessing(BackgroundJob $entity): bool + { + $qb = $this->connection->createQueryBuilder() + ->update($this->config['tableName']) + ->where('id = :__id') + ->andWhere('state IN (:__states)') + ->setParameter('__id', $entity->getId()) + ->setParameter('__states', array_values(BackgroundJob::READY_TO_PROCESS_STATES), ArrayParameterType::INTEGER); + + foreach ($entity->getDatabaseValues() as $column => $value) { + $qb->set($column, ':' . $column) + ->setParameter($column, $value); + } + + return $qb->executeStatement() > 0; + } + + /** + * Získá pojmenovaný (advisory) zámek na serialGroup. Serializuje konzumenty téže skupiny + * kolem kritické sekce [checkUnfinishedJobs + zápis PROCESSING]; různé skupiny se navzájem neblokují. + * Zámek je connection-scoped (drží se na aktuální connection, viz createConnection() v processJob). + * + * Vrací true při získání, false pokud se zámek nepodařilo získat v limitu (volající job vrátí do brokera + * a zkusí to příště) - úmyslně nevyhazuje výjimku, aby jediná kolize neshodila celý běh process()/konzumenta. + * + * @throws \Doctrine\DBAL\Exception + */ + private function acquireGroupLock(string $serialGroup): bool + { + if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + // pg_advisory_lock(bigint): klíč skládáme jako (namespace << 32) | crc32(skupina). + // Dvouargumentová varianta bere int4 a crc32 (až 2^32-1) by ji přetekla ("integer out of range"), + // proto použijeme jednoargumentovou bigint variantu se složeným 64bit klíčem (namespace odděluje + // naše zámky od ostatních v aplikaci). Timeout řešíme přes session proměnnou lock_timeout. + $this->connection->executeStatement("SET lock_timeout = '" . (self::GROUP_LOCK_TIMEOUT * 1000) . "'"); + try { + $this->connection->executeStatement('SELECT pg_advisory_lock(?)', [$this->getGroupLockId($serialGroup)]); + } catch (\Doctrine\DBAL\Exception $e) { + // Vypršení lock_timeout (nebo jiné selhání získání) - job zpracujeme příště. + return false; } - $this->connection->update($this->config['tableName'], $entity->getDatabaseValues(), ['id' => $entity->getId()]); + return true; + } + + // MySQL: název zámku musí být max 64 znaků (jinak GET_LOCK vrátí NULL); serial_group je VARCHAR(255), + // proto skupinu hashujeme přes crc32 na stabilní krátký název místo přímého vložení. + // GET_LOCK vrátí 1 při získání, 0 při timeoutu, NULL při chybě - všechny != 1 bereme jako neúspěch. + $acquired = $this->connection->fetchOne('SELECT GET_LOCK(?, ?)', [$this->getGroupLockName($serialGroup), self::GROUP_LOCK_TIMEOUT]); + return (int) $acquired === 1; + } + + /** + * Uvolní pojmenovaný zámek na serialGroup získaný přes acquireGroupLock(). + * + * @throws \Doctrine\DBAL\Exception + */ + private function releaseGroupLock(string $serialGroup): void + { + if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $this->connection->executeStatement('SELECT pg_advisory_unlock(?)', [$this->getGroupLockId($serialGroup)]); + return; } + + $this->connection->executeStatement('SELECT RELEASE_LOCK(?)', [$this->getGroupLockName($serialGroup)]); + } + + /** + * 64bit klíč zámku skupiny pro PostgreSQL: horní polovina je fixní namespace, dolní crc32 názvu skupiny. + * Výsledek se vejde do signed bigint (namespace << 32 je ~4,8e18 < PHP_INT_MAX i bigint max ~9,2e18). + */ + private function getGroupLockId(string $serialGroup): int + { + return (self::PG_LOCK_NAMESPACE << 32) | crc32($serialGroup); + } + + /** + * Krátký stabilní název zámku skupiny pro MySQL GET_LOCK (limit 64 znaků); crc32 udrží délku konstantní. + */ + private function getGroupLockName(string $serialGroup): string + { + return 'bgq:' . crc32($serialGroup); } /** diff --git a/tests/Integration/BackgroundQueueTest.php b/tests/Integration/BackgroundQueueTest.php index f35d4b4..d6a9034 100644 --- a/tests/Integration/BackgroundQueueTest.php +++ b/tests/Integration/BackgroundQueueTest.php @@ -343,9 +343,15 @@ public function testProcessOrderByPriorityInCronMode() } // V cron módu se WAITING joby zpracují opakovaným voláním process(). + // WAITING job dostane postponedBy = waitingJobExpiration (1 s), takže ho cron odbaví až další běh + // po uplynutí tohoto okna (availableFrom). Reálný cron běží jednou za minutu, takže okno dávno mine; + // v testu ho mezi běhy překleneme sleepem. $maxIterations = 20; while ($maxIterations-- > 0 && self::finishedCount('transcribe') < count($jobs)) { $backgroundQueue->process(); + if (self::finishedCount('transcribe') < count($jobs)) { + sleep(1); + } } $this->tester->assertEquals(['a', 'b', 'd', 'e', 'g', 'c', 'f'], Mailer::$processOrder, 'pořadí dle (priorita, ID)'); @@ -414,22 +420,18 @@ public function testGroupLockPrimitive() $bq1 = self::getBackgroundQueue(); $bq2 = self::getBackgroundQueue(); - // Stejná skupina: druhý zámek se nezíská, dokud první nepustí - $acquire->invoke($bq1, 'skupina'); - $this->assertThrows(\RuntimeException::class, function () use ($acquire, $bq2) { - $acquire->invoke($bq2, 'skupina'); - }); + // Stejná skupina: druhý zámek se v limitu nezíská (vrátí false), dokud první nepustí + $this->tester->assertTrue($acquire->invoke($bq1, 'skupina'), 'první zámek se získá'); + $this->tester->assertFalse($acquire->invoke($bq2, 'skupina'), 'druhý zámek na téže skupině se v limitu nezíská'); $release->invoke($bq1, 'skupina'); - $acquire->invoke($bq2, 'skupina'); // po uvolnění už projde + $this->tester->assertTrue($acquire->invoke($bq2, 'skupina'), 'po uvolnění už projde'); $release->invoke($bq2, 'skupina'); // Různé skupiny se navzájem neblokují - $acquire->invoke($bq1, 'skupinaA'); - $acquire->invoke($bq2, 'skupinaB'); + $this->tester->assertTrue($acquire->invoke($bq1, 'skupinaA'), 'skupinaA se získá'); + $this->tester->assertTrue($acquire->invoke($bq2, 'skupinaB'), 'skupinaB se získá souběžně (jiná skupina neblokuje)'); $release->invoke($bq1, 'skupinaA'); $release->invoke($bq2, 'skupinaB'); - - $this->tester->assertTrue(true, 'různé skupiny se neblokují'); } /** @@ -509,6 +511,61 @@ public function testBrokerModeEndToEnd() $this->tester->assertFalse(Mailer::$serialGroupViolation, 'sériovost nesmí být porušena'); } + /** + * Test 7 - výběr hlavy skupiny dle (priorita, ID) v no-entity větvi: findOldestUnfinishedJobIdsByGroup() + * a navazující processWaitingJobs(). Happy-path Test 6 tuhle větev nepokryje (tam se do WAITING nic nedostane), + * proto ji testujeme cíleně, včetně skupiny s "dírou" v prioritách (žádný job na nejvyšší prioritě). + * + * @see docs/priority-serialgroup.md "Test 6", poznámka o sdílení findOldestUnfinishedJobIdsByGroup() + * + * @throws ReflectionException + * @throws Exception + */ + public function testProcessWaitingJobsPicksGroupHeadByPriority() + { + $backgroundQueue = self::getBackgroundQueue(priorities: [1, 2, 3]); + + // gA: hlava = nejmenší ID v nejvyšší přítomné prioritě (1) => a2 (a3 je taky prio 1, ale má vyšší ID). + $backgroundQueue->publish('processRecording', ['a1'], 'gA', null, ModeEnum::NORMAL, null, 2); + $backgroundQueue->publish('processRecording', ['a2'], 'gA', null, ModeEnum::NORMAL, null, 1); + $backgroundQueue->publish('processRecording', ['a3'], 'gA', null, ModeEnum::NORMAL, null, 1); + // gB: díra v prioritách - žádný job na prioritě 1; nejvyšší přítomná je 2 => hlava je b2, ne b1 (prio 3). + $backgroundQueue->publish('processRecording', ['b1'], 'gB', null, ModeEnum::NORMAL, null, 3); + $backgroundQueue->publish('processRecording', ['b2'], 'gB', null, ModeEnum::NORMAL, null, 2); + + // Namapujeme značku -> ID a všechny joby ručně přepneme na WAITING (stav, který tahle větev řeší). + $ids = []; + foreach (self::fetchAllJobs($backgroundQueue) as $job) { + $ids[$job->getParameters()[0]] = $job->getId(); + } + self::rawConnection()->executeStatement( + 'UPDATE ' . $_ENV['PROJECT_DB_TABLENAME'] . ' SET state = ?', + [BackgroundJob::STATE_WAITING] + ); + + $reflectionClass = new \ReflectionClass(BackgroundQueue::class); + + // 1) findOldestUnfinishedJobIdsByGroup vrátí hlavu každé skupiny: gA -> a2, gB -> b2. + $find = $reflectionClass->getMethod('findOldestUnfinishedJobIdsByGroup'); + $find->setAccessible(true); + $heads = array_map('intval', iterator_to_array($find->invoke($backgroundQueue, BackgroundJob::STATE_WAITING))); + sort($heads); + $this->tester->assertEquals([$ids['a2'], $ids['b2']], $heads, 'hlavy skupin dle (priorita, ID)'); + + // 2) processWaitingJobs přepne právě tyto hlavy zpět na READY, zbytek zůstane WAITING. + $process = $reflectionClass->getMethod('processWaitingJobs'); + $process->setAccessible(true); + $process->invoke($backgroundQueue); + + $expectedReady = [$ids['a2'], $ids['b2']]; + foreach (self::fetchAllJobs($backgroundQueue) as $job) { + $expectedState = in_array($job->getId(), $expectedReady, true) + ? BackgroundJob::STATE_READY + : BackgroundJob::STATE_WAITING; + $this->tester->assertEquals($expectedState, $job->getState(), 'stav jobu ' . $job->getParameters()[0]); + } + } + private static function finishedCount(string $serialGroup): int { return (int) self::rawConnection()->fetchOne( diff --git a/tests/Support/Helper/Producer.php b/tests/Support/Helper/Producer.php index d3c1d4c..20bae8c 100644 --- a/tests/Support/Helper/Producer.php +++ b/tests/Support/Helper/Producer.php @@ -13,6 +13,19 @@ class Producer implements \ADT\BackgroundQueue\Broker\Producer public ?AMQPChannel $channel = null; private array $initQueues = []; + /** + * Zprávy se zpožděnou recirkulací (publikované s "expiration", tj. postponeBy). + * Reálný Producer je posílá do TTL fronty "_" s dead-letter zpět do prioritní + * fronty, takže se v ní objeví až po uplynutí "expiration" ms. Tady to modelujeme deterministicky in-memory: + * zpožděná zpráva se nevydá, dokud jsou k dispozici okamžité zprávy. Bez toho by se nekonečně recirkulující + * _processWaitingJobs (běží na nejvyšší prioritě) okamžitě vracel do své fronty a zablokoval consume() smyčku + * (livelock). V produkci k tomu nedochází právě díky tomuto zpoždění, ne přes skutečné AMQP TTL fronty + * (ty by mezi testy protékaly a nafukovaly getMessageCount). + * + * @var array + */ + private array $delayedMessages = []; + /** * Priorita se v RabbitMQ modeluje jako samostatná fronta "_" * (viz reálný Producer / Manager::getQueueWithPriority). Helper to napodobuje, @@ -20,10 +33,17 @@ class Producer implements \ADT\BackgroundQueue\Broker\Producer */ public function publish(string $id, string $queue, int $priority, ?int $expiration = null): void { + // Zpožděné doručení (postponeBy): nevkládáme do prioritní fronty hned, ale do zpožděného bufferu, + // odkud se zpráva vydá až když nejsou žádné okamžité zprávy (viz $delayedMessages a consume()). + if ($expiration) { + $this->delayedMessages[] = ['queue' => $queue, 'priority' => $priority, 'id' => $id]; + return; + } + $queueWithPriority = $queue . '_' . $priority; $this->initQueue($queueWithPriority); - $this->getChannel()->basic_publish(new AMQPMessage($id, $expiration ? ['expiration' => $expiration] : []), $queueWithPriority, '', true); + $this->getChannel()->basic_publish(new AMQPMessage($id), $queueWithPriority, '', true); $this->getChannel()->wait_for_pending_acks(); } @@ -44,14 +64,18 @@ public function publishNoop(): void */ public function consume(?string $queue = null): ?string { - foreach ($this->getSortedPriorityQueues($queue ?? 'general') as $priorityQueue) { + $base = $queue ?? 'general'; + + // Nejdřív okamžité zprávy v pořadí priorit (nižší číslo dřív), stejně jako reálný Consumer. + foreach ($this->getSortedPriorityQueues($base) as $priorityQueue) { $message = $this->getChannel()->basic_get($priorityQueue, true); if ($message !== null) { return $message->getBody(); } } - return null; + // Žádná okamžitá zpráva -> "uplynulo zpoždění recirkulace", vydáme nejprioritnější zpožděnou zprávu. + return $this->popDelayedMessage($base); } public function purge(string $queue): void @@ -68,15 +92,53 @@ public function getMessageCount(string $queue): int { $total = 0; foreach (array_keys($this->initQueues) as $declared) { - if ($declared === $queue || str_starts_with($declared, $queue . '_')) { + if ($this->matchesBaseQueue($declared, $queue)) { list(, $messageCount,) = $this->getChannel()->queue_declare($declared, true); $total += (int) $messageCount; } } + // Zpožděné zprávy jsou taky "v systému" (čekají na recirkulaci), takže je započítáme. + foreach ($this->delayedMessages as $message) { + if ($this->matchesBaseQueue($message['queue'] . '_' . $message['priority'], $queue)) { + $total++; + } + } + return $total; } + /** + * Vydá (a odebere) nejprioritnější zpožděnou zprávu pro danou základní frontu, nebo null pokud žádná není. + */ + private function popDelayedMessage(string $base): ?string + { + $bestIndex = null; + foreach ($this->delayedMessages as $index => $message) { + if (!$this->matchesBaseQueue($message['queue'] . '_' . $message['priority'], $base)) { + continue; + } + if ($bestIndex === null || $message['priority'] < $this->delayedMessages[$bestIndex]['priority']) { + $bestIndex = $index; + } + } + + if ($bestIndex === null) { + return null; + } + + $id = $this->delayedMessages[$bestIndex]['id']; + unset($this->delayedMessages[$bestIndex]); + $this->delayedMessages = array_values($this->delayedMessages); + + return $id; + } + + private function matchesBaseQueue(string $declared, string $base): bool + { + return $declared === $base || str_starts_with($declared, $base . '_'); + } + /** * Seřadí dosud inicializované prioritní podfronty dané základní fronty vzestupně podle priority. * From 34ec31a35bb0440df3964fc1a92a41ba6997f24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kud=C4=9Blka?= Date: Sun, 21 Jun 2026 11:14:43 +0200 Subject: [PATCH 09/15] Improve memory handling for background queue processing Addresses potential out-of-memory errors when processing a large number of background jobs. * Limits the number of background jobs fetched per iteration to 10,000. * Increases the PHP memory limit for the console `process` command to 1GB. --- src/BackgroundQueue.php | 2 +- src/Console/ProcessCommand.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/BackgroundQueue.php b/src/BackgroundQueue.php index 41d1ea8..2fa3f8a 100644 --- a/src/BackgroundQueue.php +++ b/src/BackgroundQueue.php @@ -217,7 +217,7 @@ public function process(): void ->setParameter('state', $states); /** @var BackgroundJob $_entity */ - foreach ($this->fetchAll($qb) as $_entity) { + foreach ($this->fetchAll($qb, 10000) as $_entity) { if ( $this->getConfig()['producer'] && diff --git a/src/Console/ProcessCommand.php b/src/Console/ProcessCommand.php index 82318ad..fb4cec3 100644 --- a/src/Console/ProcessCommand.php +++ b/src/Console/ProcessCommand.php @@ -25,6 +25,8 @@ public function __construct(private readonly BackgroundQueue $backgroundQueue) */ protected function executeCommand(InputInterface $input, OutputInterface $output): int { + ini_set('memory_limit', '1G'); + $this->backgroundQueue->process(); return self::SUCCESS; From 314262798bae2e6baf0b2a4c19d2ed3ad5fa1b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kud=C4=9Blka?= Date: Sun, 21 Jun 2026 12:01:55 +0200 Subject: [PATCH 10/15] Implement job coalescing for background queue Allows jobs to mark overlapping, unstarted jobs within the same serial group as REDUNDANT. This prevents redundant processing, for example, when a "recalculate from X" job covers the work of later "recalculate from Y (where Y >= X)" jobs. --- README.md | 15 +++++++++++ src/BackgroundQueue.php | 50 +++++++++++++++++++++++++++++++++++- src/Entity/BackgroundJob.php | 14 ++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fd7f4b2..9963d95 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,21 @@ Parametr `$serialGroup` je nepovinný - jeho zadáním zajistítě, že všechny Parametr `$identifier` je nepovinný - pomocí něj si můžete označit joby vlastním identifikátorem a následně pomocí metody `getUnfinishedJobIdentifiers(array $identifiers = [])` zjistit, které z nich ještě nebyly provedeny. +Parametr `$coalesceThreshold` je nepovinný a slouží ke slučování (coalescingu) překrývajících se jobů ve stejné `serialGroup`. Vyžaduje, aby byl nastaven i `$serialGroup` (jinak se vyhodí výjimka) - ten určuje rozsah slučování. Když se job se zadaným prahem **spustí**, označí všechny ostatní dosud nezpracované joby téže `serialGroup`, jejichž práh je **vyšší nebo stejný**, za `REDUNDANT` - tedy ty, které svým během pokryje. + +Typický příklad je úloha typu "přepočítej od X dál": job "přepočítej od dokladu 150" pohltí čekající joby "přepočítej od dokladu 151", "od 152" atd., protože je svým během stejně zpracuje. Rozhodnutí o redundanci dělá vždy ten "širší" job (s nižším prahem) v okamžiku svého spuštění - ví totiž jistě, co všechno přepočítá. Ruší se jen joby, které ještě nezačaly běžet; už běžící (`PROCESSING`) ani dokončené joby zůstanou nedotčené. + +```php +$this->backgroundQueue->publish( + 'recalculateStock', + ['itemId' => 1, 'fromDocument' => 150], + serialGroup: 'stock-item-1', // rozsah slučování (např. jedna skladová položka) + coalesceThreshold: 150, // job pohltí všechny nedokončené joby téže skupiny s prahem >= 150 +); +``` + +Pozor: pořadí zpracování v rámci `serialGroup` se řídí prioritou a ID (pořadím vložení), nikoli prahem. Coalescing tedy spolehlivě šetří práci, dokud joby s nižším prahem vznikají dříve (mají nižší ID). Pokud výjimečně vznikne dříve job s vyšším prahem, oba joby doběhnou - výsledek je korektní, jen bez úspory. + Pokud callback vyhodí `ADT\BackgroundQueue\Exception\PermanentErrorException`, záznam se uloží ve stavu `PERMANENTLY_FAILED` a je potřeba jej zpracovat ručně. Pokud callback vyhodí `ADT\BackgroundQueue\Exception\WaitingException`, záznam se uloží ve stavu `WAITING` a zkusí se zpracovat při přištím spuštění `background-queue:process` commandu (viz níže). Počítadlo pokusů se nezvyšuje. diff --git a/src/BackgroundQueue.php b/src/BackgroundQueue.php index 2fa3f8a..cb1ce7f 100644 --- a/src/BackgroundQueue.php +++ b/src/BackgroundQueue.php @@ -117,6 +117,7 @@ public function publish( ModeEnum $mode = ModeEnum::NORMAL, ?int $postponeBy = null, ?int $priority = null, + ?int $coalesceThreshold = null, ): void { if (!$callbackName) { @@ -135,6 +136,10 @@ public function publish( throw new Exception('Parameter "identifier" has to be set if "mode" is recurring.'); } + if ($coalesceThreshold !== null && !$serialGroup) { + throw new Exception('Parameter "serialGroup" has to be set if "coalesceThreshold" is used.'); + } + $priority = $this->getPriority($priority, $callbackName); $entity = new BackgroundJob(); @@ -143,6 +148,7 @@ public function publish( $entity->setCallbackName($callbackName); $entity->setParameters($parameters); $entity->setSerialGroup($serialGroup); + $entity->setCoalesceThreshold($coalesceThreshold); $entity->setIdentifier($identifier); $entity->setMode($mode); $entity->setPostponedBy($postponeBy); @@ -254,7 +260,12 @@ public function processJob(int $id): void // Další consumer dostane tuto zprávu znovu, zjistí, že není ve stavu pro zpracování a ukončí zpracování (return). // Consumer nespadne (zpráva se nezačne zpracovávat), metoda process() vrátí TRUE, zpráva se v RabbitMq se označí jako zpracovaná. if (!$entity->isReadyForProcess()) { - $this->logException('Unexpected state "' .$entity->getState() . '".', $entity); + // REDUNDANT job zařazený v brokeru je očekávaný stav (job byl coalescingem označen za nadbytečný + // až po zařazení jeho ID do brokera), proto ho tiše přeskočíme bez logu. Ostatní ne-ready stavy + // (FINISHED, PROCESSING jiným konzumentem, ...) logujeme dál. + if ($entity->getState() !== BackgroundJob::STATE_REDUNDANT) { + $this->logException('Unexpected state "' .$entity->getState() . '".', $entity); + } return; } @@ -313,6 +324,13 @@ public function processJob(int $id): void if (!$this->save($entity)) { return; } + + // Tento job se právě rozeběhl a svým během pokryje všechny ostatní nedokončené joby téže + // serialGroup s vyšším nebo stejným prahem (coalesce_threshold >= náš), proto je označíme za + // REDUNDANT. Běží to pod zámkem skupiny, takže žádný z nich mezitím nezačne zpracovávat. + if ($entity->getCoalesceThreshold() !== null) { + $this->markCoalescedJobsRedundant($entity); + } } catch (Exception $e) { $this->logException(self::UNEXPECTED_ERROR_MESSAGE, $entity, $e); return; @@ -479,6 +497,7 @@ public function updateSchema(): void $table->addColumn('number_of_attempts', Types::INTEGER)->setNotnull(true)->setDefault(0); $table->addColumn('error_message', Types::TEXT)->setNotnull(false); $table->addColumn('serial_group', Types::STRING, ['length' => 255])->setNotnull(false); + $table->addColumn('coalesce_threshold', Types::BIGINT)->setNotnull(false); $table->addColumn('identifier', Types::STRING, ['length' => 255])->setNotnull(false); $table->addColumn('postponed_by', Types::INTEGER)->setNotnull(false); $table->addColumn('processed_by_broker', Types::BOOLEAN)->setNotnull(true)->setDefault(0); @@ -611,6 +630,35 @@ private function isRedundant(BackgroundJob $entity): bool return (bool) $this->fetchAll($qb, 1); } + /** + * Označí za REDUNDANT všechny ostatní dosud nezpracované joby téže serialGroup, jejichž práh + * (coalesce_threshold) je vyšší nebo stejný jako u daného jobu - tedy ty, které tento právě spuštěný + * job svým během pokryje (typicky "přepočítej od X dál" pohltí všechny "přepočítej od >=X dál"). + * + * Bere jen joby v nedokončených stavech, které ještě nezačaly běžet (READY_TO_PROCESS_STATES) - běžící + * (PROCESSING) ani dokončené joby nerušíme. Volá se zevnitř kritické sekce pod zámkem skupiny. + * + * @throws \Doctrine\DBAL\Exception + */ + private function markCoalescedJobsRedundant(BackgroundJob $entity): void + { + $this->connection->createQueryBuilder() + ->update($this->config['tableName']) + ->set('state', ':redundantState') + ->where('queue LIKE :queue') + ->andWhere('state IN (:states)') + ->andWhere('serial_group = :serialGroup') + ->andWhere('id <> :selfId') + ->andWhere('coalesce_threshold >= :threshold') + ->setParameter('redundantState', BackgroundJob::STATE_REDUNDANT) + ->setParameter('queue', $this->config['queue'] . '%') + ->setParameter('states', array_values(BackgroundJob::READY_TO_PROCESS_STATES), ArrayParameterType::INTEGER) + ->setParameter('serialGroup', $entity->getSerialGroup()) + ->setParameter('selfId', $entity->getId()) + ->setParameter('threshold', $entity->getCoalesceThreshold()) + ->executeStatement(); + } + /** * Vrátí ID "předchůdce" daného jobu v jeho serialGroup, tj. jobu, který má jít před ním, jinak null. * Předchůdce = cokoli ve skupině, co zrovna běží (PROCESSING - vzájemné vyloučení bez ohledu na prioritu), diff --git a/src/Entity/BackgroundJob.php b/src/Entity/BackgroundJob.php index d9a6ed2..fb1874f 100644 --- a/src/Entity/BackgroundJob.php +++ b/src/Entity/BackgroundJob.php @@ -48,6 +48,7 @@ final class BackgroundJob private int $numberOfAttempts = 0; private ?string $errorMessage = null; private ?string $serialGroup = null; + private ?int $coalesceThreshold = null; private ?string $identifier = null; private ?int $postponedBy = null; private bool $processedByBroker = false; @@ -125,6 +126,17 @@ public function setSerialGroup(?string $serialGroup): self return $this; } + public function getCoalesceThreshold(): ?int + { + return $this->coalesceThreshold; + } + + public function setCoalesceThreshold(?int $coalesceThreshold): self + { + $this->coalesceThreshold = $coalesceThreshold; + return $this; + } + /** * @return array * @throws JsonException @@ -312,6 +324,7 @@ public static function createEntity(array $values): self $entity->numberOfAttempts = $values['number_of_attempts']; $entity->errorMessage = $values['error_message']; $entity->serialGroup = $values['serial_group']; + $entity->coalesceThreshold = $values['coalesce_threshold']; $entity->identifier = $values['identifier']; $entity->mode = ModeEnum::from($values['mode']); $entity->postponedBy = $values['postponed_by']; @@ -340,6 +353,7 @@ public function getDatabaseValues(): array 'number_of_attempts' => $this->numberOfAttempts, 'error_message' => $this->errorMessage, 'serial_group' => $this->serialGroup, + 'coalesce_threshold' => $this->coalesceThreshold, 'identifier' => $this->identifier, 'mode' => $this->mode->value, 'postponed_by' => $this->postponedBy, From c39ace44bbaeb6c74aab8a1bf2cfb508915f11a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kud=C4=9Blka?= Date: Sun, 21 Jun 2026 13:22:17 +0200 Subject: [PATCH 11/15] Lower background queue job fetch limit Reduces the maximum number of background jobs fetched and processed per iteration from 10,000 to 1,000. While the previous increase to 10,000 aimed to reduce database round-trips, processing such a large batch concurrently could still lead to excessive memory consumption for specific job types, resulting in out-of-memory errors. This adjustment provides a better balance between database efficiency and peak memory usage during job execution. --- src/BackgroundQueue.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BackgroundQueue.php b/src/BackgroundQueue.php index cb1ce7f..52602f8 100644 --- a/src/BackgroundQueue.php +++ b/src/BackgroundQueue.php @@ -223,7 +223,7 @@ public function process(): void ->setParameter('state', $states); /** @var BackgroundJob $_entity */ - foreach ($this->fetchAll($qb, 10000) as $_entity) { + foreach ($this->fetchAll($qb, 1000) as $_entity) { if ( $this->getConfig()['producer'] && From 748a235800c38988f7a5de025c03fc3215a9521f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kud=C4=9Blka?= Date: Mon, 22 Jun 2026 08:10:10 +0200 Subject: [PATCH 12/15] Handle deadlocks in job coalescing The `markCoalescedJobsRedundant` operation can encounter transient database deadlocks (1213) during concurrent writes. This could leave jobs stuck in a PROCESSING state. This change adds a retry mechanism with a back-off delay for the coalesce UPDATE to overcome these transient deadlocks. Additionally, a new index `(serial_group, coalesce_threshold)` is added to optimize the WHERE clause, reducing lock contention and preventing deadlocks by allowing targeted index scans. --- src/BackgroundQueue.php | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/BackgroundQueue.php b/src/BackgroundQueue.php index 52602f8..fe55fab 100644 --- a/src/BackgroundQueue.php +++ b/src/BackgroundQueue.php @@ -17,6 +17,7 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Exception\DeadlockException; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Query\QueryBuilder; use Doctrine\DBAL\Schema\AbstractSchemaManager; @@ -44,6 +45,11 @@ class BackgroundQueue // Kritická sekce [check + zápis PROCESSING] trvá jen pár mikrosekund, takže tato hodnota je strop pro souběh, ne pro práci. private const GROUP_LOCK_TIMEOUT = 3; + // Coalesce UPDATE (markCoalescedJobsRedundant): kolikrát ho zopakovat při deadlocku a základní prodleva (µs) + // mezi pokusy. Prodleva se s každým pokusem lineárně prodlužuje (delay * číslo pokusu). + private const COALESCE_DEADLOCK_MAX_ATTEMPTS = 5; + private const COALESCE_DEADLOCK_RETRY_DELAY = 100000; // 100 ms + private array $config; private bool $connectionCreated = false; private Connection $connection; @@ -512,6 +518,9 @@ public function updateSchema(): void $table->setPrimaryKey(['id']); $table->addIndex(['identifier']); $table->addIndex(['state']); + // Pro coalesce UPDATE (markCoalescedJobsRedundant) - bez něj se WHERE vyhodnocuje scanem přes index na + // 'state' napříč všemi skupinami, který rozsahově zamyká i nesouvisející řádky a způsobuje deadlocky. + $table->addIndex(['serial_group', 'coalesce_threshold']); $schemaManager = $this->createSchemaManager(); if ($schemaManager->tablesExist([$this->config['tableName']])) { @@ -638,11 +647,15 @@ private function isRedundant(BackgroundJob $entity): bool * Bere jen joby v nedokončených stavech, které ještě nezačaly běžet (READY_TO_PROCESS_STATES) - běžící * (PROCESSING) ani dokončené joby nerušíme. Volá se zevnitř kritické sekce pod zámkem skupiny. * + * UPDATE může výjimečně narazit na deadlock (1213) se souběžnými zápisy jiných konzumentů (claim, jiný + * coalesce). Deadlock je tranzientní, proto ho omezeně opakujeme s krátkou prodlevou - jinak by job + * uvázl v PROCESSING (volající catch by ho jen zalogoval a vrátil bez spuštění callbacku). + * * @throws \Doctrine\DBAL\Exception */ private function markCoalescedJobsRedundant(BackgroundJob $entity): void { - $this->connection->createQueryBuilder() + $qb = $this->connection->createQueryBuilder() ->update($this->config['tableName']) ->set('state', ':redundantState') ->where('queue LIKE :queue') @@ -655,8 +668,20 @@ private function markCoalescedJobsRedundant(BackgroundJob $entity): void ->setParameter('states', array_values(BackgroundJob::READY_TO_PROCESS_STATES), ArrayParameterType::INTEGER) ->setParameter('serialGroup', $entity->getSerialGroup()) ->setParameter('selfId', $entity->getId()) - ->setParameter('threshold', $entity->getCoalesceThreshold()) - ->executeStatement(); + ->setParameter('threshold', $entity->getCoalesceThreshold()); + + $attempt = 0; + while (true) { + try { + $qb->executeStatement(); + return; + } catch (DeadlockException $e) { + if (++$attempt >= self::COALESCE_DEADLOCK_MAX_ATTEMPTS) { + throw $e; + } + usleep(self::COALESCE_DEADLOCK_RETRY_DELAY * $attempt); + } + } } /** From 1c78ab5fdb664e316bae7b275b5611edd65fccb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kud=C4=9Blka?= Date: Mon, 22 Jun 2026 11:36:35 +0200 Subject: [PATCH 13/15] Mark jobs temporarily failed on persistent coalescing deadlocks When the `markCoalescedJobsRedundant` operation fails due to a deadlock that persists even after internal retries, the job would otherwise remain stuck in a PROCESSING state. This ensures such jobs are transitioned to TEMPORARILY_FAILED, allowing them to be picked up for reprocessing in a subsequent run, preventing them from getting permanently stuck. --- src/BackgroundQueue.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/BackgroundQueue.php b/src/BackgroundQueue.php index fe55fab..6030609 100644 --- a/src/BackgroundQueue.php +++ b/src/BackgroundQueue.php @@ -337,6 +337,20 @@ public function processJob(int $id): void if ($entity->getCoalesceThreshold() !== null) { $this->markCoalescedJobsRedundant($entity); } + } catch (DeadlockException $e) { + // Coalesce UPDATE neuspěl ani po opakování (deadlock přetrval). Job je v tuto chvíli už ve stavu + // PROCESSING, takže ho nesmíme jen vrátit (uvázl by) - přepneme ho na TEMPORARILY_FAILED, aby ho + // vzal příští běh; coalescing se zopakuje při dalším spuštění. + try { + $entity->setState(BackgroundJob::STATE_TEMPORARILY_FAILED) + ->setErrorMessage($e->getMessage()) + ->setPostponedBy(self::getPostponement($entity->getNumberOfAttempts())); + $this->save($entity); + $this->publishToBroker($entity); + } catch (Exception $innerEx) { + $this->logException(self::UNEXPECTED_ERROR_MESSAGE, $entity, $innerEx); + } + return; } catch (Exception $e) { $this->logException(self::UNEXPECTED_ERROR_MESSAGE, $entity, $e); return; From f4c3fe40de17cca6b1787f06dc38cbfb22a6c6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kud=C4=9Blka?= Date: Sun, 28 Jun 2026 10:07:37 +0200 Subject: [PATCH 14/15] Allow dynamic AMQP arguments based on queue name Introduces an optional `queueArguments` parameter to the `Manager` constructor. This enables applying specific AMQP arguments, such as `x-single-active-consumer`, to queues whose names contain a defined string. This provides more granular and flexible control over queue behavior without requiring separate queue parameter definitions for each unique configuration. --- README.md | 9 ++++++++- src/Broker/PhpAmqpLib/Manager.php | 13 ++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9963d95..900e983 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,14 @@ $queueParams = [ 'arguments' => ['x-queue-type' => ['S', 'quorum']] ]; -$manager = new \ADT\BackgroundQueue\Broker\PhpAmqpLib\Manager($connectionParams, $queueParams); +// nepovinné: per-queue AMQP argumenty +// klíč = část názvu fronty, hodnota = argumenty aplikované na každou frontu, jejíž název daný řetězec obsahuje +// příklad: fronta "transcribe" poběží přes single active consumer (jen jeden aktivní consumer napříč všemi) +$queueArguments = [ + 'transcribe' => ['x-single-active-consumer' => ['t', true]] +]; + +$manager = new \ADT\BackgroundQueue\Broker\PhpAmqpLib\Manager($connectionParams, $queueParams, $queueArguments); $producer = new \ADT\BackgroundQueue\Broker\PhpAmqpLib\Producer(); $consumer = new \ADT\BackgroundQueue\Broker\PhpAmqpLib\Consumer(); diff --git a/src/Broker/PhpAmqpLib/Manager.php b/src/Broker/PhpAmqpLib/Manager.php index 620186e..b360e3a 100644 --- a/src/Broker/PhpAmqpLib/Manager.php +++ b/src/Broker/PhpAmqpLib/Manager.php @@ -13,6 +13,7 @@ class Manager private array $connectionParams; private array $queueParams; + private array $queueArguments; private ?AMQPStreamConnection $connection = null; private ?AMQPChannel $channel = null; @@ -22,10 +23,15 @@ class Manager private array $initExchanges; private bool $initQos = false; - public function __construct(array $connectionParams, array $queueParams) + /** + * @param array $queueArguments Map of queue name needle => additional AMQP arguments. + * Arguments are applied to every queue whose name contains the given needle. + */ + public function __construct(array $connectionParams, array $queueParams, array $queueArguments = []) { $this->connectionParams = $connectionParams; $this->queueParams = $queueParams; + $this->queueArguments = $queueArguments; } private function getConnection(): AMQPStreamConnection @@ -109,6 +115,11 @@ public function createQueue(string $queue, ?string $exchange = null, array $addi } $arguments = $this->queueParams['arguments']; + foreach ($this->queueArguments as $needle => $queueArguments) { + if (str_contains($queue, $needle)) { + $arguments = array_merge($arguments, $queueArguments); + } + } if ($additionalArguments) { $arguments = array_merge($arguments, $additionalArguments); } From fc912a39bef1f3ce458fb94079c92e4d712b8e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kud=C4=9Blka?= Date: Sun, 28 Jun 2026 10:13:02 +0200 Subject: [PATCH 15/15] Order background jobs by ID for consistent processing Ensures jobs are processed in a predictable and consistent order, preventing potential starvation of older jobs. --- src/BackgroundQueue.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/BackgroundQueue.php b/src/BackgroundQueue.php index 6030609..cb08cda 100644 --- a/src/BackgroundQueue.php +++ b/src/BackgroundQueue.php @@ -226,7 +226,8 @@ public function process(): void $qb = $this->createQueryBuilder() ->andWhere('state IN (:state)') - ->setParameter('state', $states); + ->setParameter('state', $states) + ->orderBy('id', 'ASC'); /** @var BackgroundJob $_entity */ foreach ($this->fetchAll($qb, 1000) as $_entity) {