Cron presentery#
Naplánovaná úloha je v tomhle CMS presenter jako každý jiný — spouští se HTTP
požadavkem s tokenem a vrací JSON. Kód: app/UI/Cron/, základ
app/Core/Base/Cron/BasePresenter.php.
Dnes je to 72 akcí v 9 modulech (grep -rn "public function action" app/UI/Cron
--include="*Presenter.php" | wc -l). Jejich seznam se do manuálu
generuje z kódu.
Co base zařizuje sám#
| Krok | Co se stane |
|---|---|
initializeSetting() |
načte nastavení (kvůli tokenu) |
verifyToken() |
porovná ?token= s 1/cron.token přes hash_equals(); neshoda → 403 |
| zámek souběhu | CronLockManager::acquire() pod jménem cron.<modul>.<presenter>.<akce> malými písmeny (cron.eshop.productimport.run), viz Souběh |
| operace | tryCall() obalí metodu action<Akce>() operací se zdrojem cron; startup s nastavením, tokenem a zámkem zůstává mimo |
shutdown() |
zámek uvolní |
K tomu dvě metody odpovědi: sendSuccess($data) → {"status":"ok", …}
a sendError($message, $code) → {"status":"error", …}.
Prázdný cron.token v nastavení zamkne VŠECHNY úlohy
verifyToken() odmítne požadavek i tehdy, když je očekávaný token prázdný — jinak
by prázdné nastavení znamenalo veřejné endpointy. Na novém prostředí je proto
potřeba token nastavit dřív, než se crony zapojí; jinak vrací 403 a vypadá to jako
špatná adresa.
Token se posílá v query stringu, tedy se loguje
Objeví se v access logu webserveru a v historii shellu. Neber ho jako tajemství srovnatelné s heslem — je to ochrana proti náhodnému spuštění, ne proti útočníkovi s přístupem k logům.
Postup: zakládám novou úlohu#
- Presenter do
app/UI/Cron/<Modul>/Presenters/XxxPresenter.php,extends App\UI\Cron\BasePresenter, v konstruktoru jen manager. - Akce je pár řádků — zavolat manager a poslat výsledek:
/**
* Přepočítá denormalizovaný sloupec price u všech inzerátů.
*
* Spouštět periodicky, např. každých 10–15 minut.
*
* URL: /cron/bazaar/advert/recount-prices?token=xxx
*/
public function actionRecountPrices(): void
{
$this->sendSuccess($this->advertManager->recountPrices());
}
- Do docblocku napiš
Frekvence:neboSpouštět— z toho se generuje sloupec „Jak často“ v manuálu. Bez toho se při sestavení manuálu ozve varování. - Do docblocku napiš i
URL:— generátor z něj bere adresu. - U neperiodické úlohy napiš rovnou „ručně“; vymýšlet jí cron výraz je horší než nic.
- Doplň úlohu do checklistu nasazení — generátor jinak při sestavení manuálu ohlásí, že v plánu chybí.
- Ověř spuštěním — se správným tokenem i bez něj.
Logika v cron presenteru se nedá zavolat odjinud ani otestovat
Cron presenter injektuje jen managery — nikdy service vrstvu, generátory nebo
EntityManagerInterface. Co je v presenteru, existuje jen pro cron; totéž pak
potřebuje admin a napíše se to podruhé.
Souběh#
Zámek má dvě vrstvy a obě bere CronLockManager::acquire():
| Vrstva | Co dělá | Proč |
|---|---|---|
MySQL GET_LOCK() |
pojmenovaný zámek drží spojení procesu, dokud ho release() nepustí nebo proces neskončí (i pádem) |
rozhoduje: živému běhu nevyprší, ať trvá jakkoli dlouho |
řádek v cms_cron_locks |
INSERT IGNORE na klíč name s ttl (výchozí 300 s); řádek po TTL se před vložením smaže |
viditelnost pro isLocked(), hlídky „nejvýš jednou za“ a proces staré verze kódu během nasazení |
Druhá instance dostane {"status":"skipped","reason":"already_running"}. Nevypršelý řádek bez
držitele GET_LOCK() (pád v rámci TTL, starý kód) se respektuje také. release() maže jen
zámek, který tahle instance opravdu drží, takže nesmaže zámek jiného běhu.
Jméno pro GET_LOCK() obsahuje název databáze (zámek platí pro celý DB server a dva weby na
jednom serveru by se jinak blokovaly) a nad 64 znaků se hashuje.
Zámek je fail-open
Když GET_LOCK() selže nebo vrátí NULL, acquire() vrátí true a úloha běží bez
zámku. Je to záměr, zámek nesmí zablokovat cron. Chybí-li tabulka cms_cron_locks, drží se
aspoň GET_LOCK(), ale hlídky „nejvýš jednou za“ přestanou fungovat a úklid poběží při
každém spuštění.
Dlouhá úloha zablokuje ta následující spuštění
Běží-li reindex hodinu a plánovač je nastavený na deset minut, pět spuštění se
zahodí jako skipped. Frekvenci volte podle skutečné doby běhu, ne podle toho,
jak často by se to hodilo.
Pooler spojení zámek tiše ztratí
GET_LOCK() patří spojení. Za ProxySQL nebo jiným poolerem, který mezi dotazy střídá
spojení, a po obnově spojení uprostřed běhu zámek zmizí a zbývá jen řádek s TTL: úloha delší
než TTL bez refresh() se pak může rozběhnout dvakrát.
Nad zámkem základu se v úlohách opakují tři vzory, všechny přes CronLockManager
(acquire($name, $ttl), refresh(), release(), isLocked()):
| Vzor | K čemu | Jak | Příklad |
|---|---|---|---|
| zámek sdílený s tlačítkem v administraci | ruční a cronový běh se nesmí potkat | admin akce bere týž název zámku jako cron | feedy (cron.eshop.feed.generate), dogenerování variant obrázků |
| vlastní zámek s obnovou | úloha běží déle než 300 s | vlastní název a TTL, refresh() po každé dávce, release() ve finally |
import produktů (TTL 900 s) |
| hlídka „nejvýš jednou za“ | úklid, který nemá běžet častěji, i když ho někdo zavěsí do častějšího cronu | acquire(GUARD_NAME, GUARD_TTL) a po úspěchu zámek neuvolnit; vyprší sám a další běh ho převezme |
úklid logů, dočasných souborů, osiřelých notifikací, zrušených účtů (20 h), sonda aktivace plateb (10 min) |
Postup: přidávám hlídku „nejvýš jednou za“#
- Konstanta
GUARD_NAME(název bez prefixucron., ať se nepotká se zámkem základu) aGUARD_TTLo něco kratší než perioda — u denní úlohy 20 h, ne 24 h. - Na začátku akce
acquire(GUARD_NAME, GUARD_TTL); přifalsevrať úspěch seskipped: ran_recently. - Při výjimce zámek uvolni a teprve pak
sendError(). - Parametry z adresy (
?days=) hlídku neobcházejí — to je záměr.
Hlídka s TTL přesně na periodu přeskočí každý druhý běh
Denní crontab vystřelí o pár sekund dřív, než před 24 hodinami doběhl předchozí běh,
zámek ještě platí a úloha skončí skipped. Proto 20 h.
Hlídka neuvolněná po chybě zablokuje úlohu na celou periodu
Bez release() v catch se po jednom selhání úklid zopakuje až druhý den — a pokud
selhává opakovaně, neproběhne nikdy, přestože cron „běží“.
Operace v cronu#
Akce Cronu je jedna operace: na začátku a na konci se vyprázdní identity mapa Doctrine, uvnitř ne.
Výsledek operace se bere z odpovědi, kterou akce odeslala: ok je úspěch, skipped je
„neznámo" (část dávky mohla proběhnout) a error je selhání. Jiná výjimka je selhání a znovu
se vyhodí. Při ladění (mimo produkční režim) skončí akce výjimkou, když na konci zůstane
nezapsaná práce nebo otevřená transakce, viz porušení kontraktu.
Dlouhá dávka drží celou akci v paměti
Mezi dávkami identity mapu nic nečistí, jen konec akce. Po každé dávce proto zavolej
finishBatch() z UnitOfWorkReset, jinak akce drží všechny načtené entity.
Dávky#
Úlohy zpracovávají po dávkách; velikost je v nastavení modulu (7/payment.batchSize,
7/schedule.batchSize, …), typicky 10.
Menší dávka častěji je lepší než velká zřídka
Přerušení uprostřed velké dávky znamená, že se část práce udělá dvakrát — a u idempotentních operací to nevadí, u těch ostatních ano.
Dva druhy chyby#
Zpracování položky může selhat dvěma způsoby a rozlišit je je nutné:
| Druh | Chování | Příklad |
|---|---|---|
| přechodná | příznak odbavení se neposune, položka se zkusí příště — u front s evidencí selhání s rostoucím odstupem | chyba uložení, dočasně nedostupná služba |
| trvalá | položka se odbaví bez výsledku a zapíše do chyb k ručnímu vyřešení | osiřelý odkaz, chybějící zdrojový záznam |
Bez rozlišení jedna vadná položka vyhladoví celou frontu
Trvale chybná položka, která se zkouší donekonečna, ukrajuje z dávky každý běh a ostatní se nikdy nedostanou na řadu. Proto se odbaví s poznámkou a jde se dál — viz Fakturace, oddíl o automatice.
Odbavená trvalá chyba znamená, že se výsledek nikdy neudělá
Zaplacená objednávka bez faktury nikoho neupozorní. Přehled chyb se musí kontrolovat, jinak je „úloha proběhla v pořádku“ zavádějící.
Selhávající položka fronty#
Cronová fronta, která bere položky po dávkách (expirace objednávek a reklamací, doklady z plateb
a dohánění dokladů, rozvrh faktur, upozornění na expiraci inzerátů a firem), používá společnou
evidenci cms_system_cron_failures a CronFailureManager. Řádek je klíčovaný dvojicí
(fronta, id položky) a drží počet neúspěchů, zkrácenou poslední chybu a next_attempt_at.
| Krok | Volání |
|---|---|
| před výběrem dávky | findParkedIds($queue) — id položek, na které ještě není čas; fronta je vyloučí z dotazu (NotInFilter) a dávka se doplní dalšími řádky |
| po neúspěchu | recordFailure($queue, $itemId, $error) — odstup 1 min, 5 min, 30 min, 2 h, pak 24 h (RETRY_BACKOFF_MINUTES) |
| po úspěchu | recordSuccess($queue, $itemId) — řádek se smaže |
| na konci běhu | sendExhaustedNotification($queue) — po MAX_ATTEMPTS (5) neúspěších za sebou jedna notifikace CRON_ITEM_FAILED za běh fronty, ne za položku |
Po vyčerpání pokusů zůstává položka odstavená a zkouší se jednou za 24 hodin. Řádek, který se
7 dní neobnovil, patří položce, která z fronty odešla jinak, a uklidí se při dalším výběru.
Zápisy jdou přes raw DBAL (žádné UnitOfWork::clear() uprostřed dávky); chyba evidence se jen
zaloguje a fronta jede dál bez odstupu. Odkaz notifikace míří na řádek evidence — až se položka
vzpamatuje, řádek zmizí a úklid osiřelých notifikací (sonda CronFailureNotificationTargetProbe)
notifikaci od cíle odpojí: zůstane ve zvonku jako text bez odkazu.
Nová fronta s dávkou položek
Zavolej findParkedIds() před výběrem, recordFailure() / recordSuccess() u každé
položky a sendExhaustedNotification() po dávce. Název fronty je konstanta v manageru
(např. eshop.order.expire). Trvale chybná položka tak nevyhladoví ostatní.
Identita a oprávnění#
Cron obchází RBAC guard na interní cestě
Injektuje Api managery přímo, ne přes ApiBridge — takže internalMode se ho
netýká. Změřeno: nula řádků API_RBAC path=internal za šest běhů.
CLI skript přes ApiBridge běží jako guest
To je něco jiného než cron. Skript bez session ve vynucujícím režimu nedostane data
a vrátí tiché prázdno, ne chybu. Musí si identitu dodat sám:
ApiBridge::setInternalAuthToken($jwtManager->generateToken(…)). Podrobně
RBAC v API.
Pole se v cronu NEfiltrují podle publika
Mimo požadavek není žádný volající, takže #[FieldAccess] se neuplatní. Je to
záměr — aktivační e-mail potřebuje activationKey — ale znamená to, že co cron
pošle ven, si musí ohlídat sám.
Kam sáhnout#
| Chci | Kde |
|---|---|
| co base zařizuje | app/Core/Base/Cron/BasePresenter.php |
| zámek souběhu | app/UI/Api/System/Models/Managers/CronLockManager.php |
| existující úlohy | app/UI/Cron/<Modul>/Presenters/ |
| seznam všech úloh | Naplánované úlohy |
| generátor toho seznamu | manual/tools/gen-cron-list.py |
Navazující kapitoly: Naplánované úlohy · Checklist nasazení · RBAC v API · Konvence kódu