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/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. diff --git a/docs/priority-serialgroup.md b/docs/priority-serialgroup.md new file mode 100644 index 0000000..578a36d --- /dev/null +++ b/docs/priority-serialgroup.md @@ -0,0 +1,653 @@ +# 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:'+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. + +**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(); +``` + +`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): 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(?, ?)", + ['bgq:' . crc32($serialGroup), self::GROUP_LOCK_TIMEOUT] + ); + 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:' . crc32($serialGroup)] + ); +} +``` + +PostgreSQL (`pg_advisory_lock` / `pg_advisory_unlock`): + +```php +private function acquireGroupLock(string $serialGroup): bool +{ + // 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 << 32) | crc32($serialGroup)] + ); +} +``` + +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): + +| Č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, 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 + +- **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')` → 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í 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. + +### 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. + +### 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 | +|------|---------|-----------| +| 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žší | +| 7 | výběr hlavy skupiny dle (priorita, ID), `findOldestUnfinishedJobIdsByGroup`/`processWaitingJobs` | deterministická reprodukce, červený → zelený | 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 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 ddc459a..d6a9034 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; @@ -29,6 +30,7 @@ class BackgroundQueueTest extends Unit protected function _before() { parent::_before(); + Mailer::reset(); self::clear(); } @@ -68,14 +70,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 +89,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 +111,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 +139,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 +179,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 +204,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 +246,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,15 +254,336 @@ 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) { + } + } + + /** + * 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(). + // 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(1, self::$producer->getMessageCount('general'), 'general after 1s'); - $this->tester->assertEquals(0, self::$producer->getMessageCount('waiting'), 'waiting after 1s'); } } + + $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 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'); + $this->tester->assertTrue($acquire->invoke($bq2, 'skupina'), 'po uvolnění už projde'); + $release->invoke($bq2, 'skupina'); + + // Různé skupiny se navzájem neblokují + $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'); + } + + /** + * 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'); + } + + /** + * 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( + '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) { @@ -269,25 +593,34 @@ 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 + private static function getBackgroundQueue(bool $producer = false, bool $waitingQueue = false, bool $logger = false, array $priorities = [1]): BackgroundQueue { - return new BackgroundQueue([ + $bq = new BackgroundQueue([ 'callbacks' => [ 'process' => [new Mailer(), 'process'], 'processWithTemporaryError' => [new Mailer(), 'processWithTemporaryError'], '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' => self::getDsn(), + '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, @@ -298,6 +631,13 @@ private static function getBackgroundQueue(bool $producer = false, bool $waiting } } ]); + $bq->updateSchema(); + return $bq; + } + + private static function rawConnection(): \Doctrine\DBAL\Connection + { + return DriverManager::getConnection(BackgroundQueue::parseDsn(self::getDsn())); } private static function getDsn() @@ -314,7 +654,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/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/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 2ae76ad..20bae8c 100644 --- a/tests/Support/Helper/Producer.php +++ b/tests/Support/Helper/Producer.php @@ -13,22 +13,69 @@ 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 + /** + * 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, + * 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); + // 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] : []), $queue, '', true); + $this->getChannel()->basic_publish(new AMQPMessage($id), $queueWithPriority, '', true); $this->getChannel()->wait_for_pending_acks(); } + public function publishDie(string $queue): void + { + + } + 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); + $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(); + } + } + + // Žá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 @@ -37,11 +84,77 @@ 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 ($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. + * + * @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) @@ -87,4 +200,4 @@ public function __destruct() $this->channel->close(); $this->connection->close(); } -} \ No newline at end of file +} 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())); - } -}