Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
UI vrstva / Cron presentery

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.

/cron/<modul>/<presenter>/<akce>?token=xxx

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#

  1. Presenter do app/UI/Cron/<Modul>/Presenters/XxxPresenter.php, extends App\UI\Cron\BasePresenter, v konstruktoru jen manager.
  2. 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());
}
  1. Do docblocku napiš Frekvence: nebo Spouštět — z toho se generuje sloupec „Jak často“ v manuálu. Bez toho se při sestavení manuálu ozve varování.
  2. Do docblocku napiš i URL: — generátor z něj bere adresu.
  3. U neperiodické úlohy napiš rovnou „ručně“; vymýšlet jí cron výraz je horší než nic.
  4. Doplň úlohu do checklistu nasazení — generátor jinak při sestavení manuálu ohlásí, že v plánu chybí.
  5. 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“#

  1. Konstanta GUARD_NAME (název bez prefixu cron., ať se nepotká se zámkem základu) a GUARD_TTL o něco kratší než perioda — u denní úlohy 20 h, ne 24 h.
  2. Na začátku akce acquire(GUARD_NAME, GUARD_TTL); při false vrať úspěch se skipped: ran_recently.
  3. Při výjimce zámek uvolni a teprve pak sendError().
  4. 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

Nastavení cookies