Identity mapa a hranice operace#
Doctrine drží načtené entity v identity mapě (UnitOfWork). CMS ji nevyprazdňuje při čtení,
ale na hranicích operace. Kdo to zná, ví, kdy je entita ještě „živá" a kdy už jen
odpojený objekt; kdo ne, narazí na uložení, které selže jinde, než kde vznikla chyba.
Co je operace#
Operace je jedna smysluplná akce nad daty: jedno Api volání (internalAction<Akce>()
Api presenteru), jedna akce Cronu nebo jeden krok skriptu v příkazové řádce. Má začátek a konec
a mezi nimi smí číst a zapisovat, jak potřebuje.
| Vstup | Kdo operaci otevírá | source v logu |
|---|---|---|
| Api přes HTTP | BasePresenter::operation() v těle internalAction<Akce>() |
http |
| Api přes interní most (Front a Admin) | totéž, stejným kódem | bridge |
| akce Cronu | Cron\BasePresenter::tryCall() kolem metody action<Akce>() |
cron |
bin/* a skripty z příkazové řádky |
OperationRunner::cli($container, $name, $body) |
cli |
Operace spuštěná uvnitř jiné operace se k ní jen připojí (join) a nic nečistí. Čistí se jen na hranicích kořenové operace.
Co se na hranici děje#
DoctrineOperationParticipant (app/Core/Doctrine/Operations/) hlídá identity mapu:
- Začátek: vyprázdní mapu, ale jen když je
EntityManagerotevřený, neběží žádná transakce a nic není naplánováno k zápisu. - Konec: totéž. Selhala-li operace uvnitř otevřené transakce, mapu nechá tomu, kdo transakci vrací zpět.
- Čtení nečistí nikdy.
find(),findBy()anifindAll()mapu nevyprazdňují.
Hranice nikdy nespouští flush() ani žádné SQL. Jen mapu vyprázdní, a co nebylo uloženo, je pryč.
Porušení kontraktu#
Když operace začne s rozpracovanou prací, skončí s nezapsanými změnami (succeeded nebo
unknown) nebo nechá otevřenou transakci, je to porušení kontraktu:
| Kód | Význam |
|---|---|
begin.pending_work |
operace začala, ale něco už čekalo na flush() |
end.pending_work |
operace skončila úspěšně a něco zůstalo nezapsané |
end.transaction_left_open |
operace otevřela transakci a nezavřela ji |
begin.transaction_active, begin.em_closed |
jen se zapíše do logu, nikdy nevyhodí výjimku |
Co se stane, řídí params.1.operations.contractViolation v config/Shared/operations.neon:
| Hodnota | Chování |
|---|---|
auto (výchozí) |
mimo produkční režim Tracy (ladění) výjimka OperationContractViolationException, v produkci jen řádek v logu |
throw |
vždy výjimka |
log |
nikdy výjimka |
Výjimka přijde až po úklidu (rollback transakcí, které operace nechala otevřené, a koncové vyprázdnění) a nikdy nepřekryje vlastní výjimku operace.
Co z toho plyne pro kód#
Entita hranici operace nepřekročí
Entita načtená v jedné operaci je po jejím konci odpojená. Čtení a gettery fungují dál,
ale flush() ji už nezná. Ven z operace proto patří hodnoty, ID a Result, ne entity.
Kde to zabolí, ukazují tři projevy. Vznikají, když se mapa vyprázdní mezi načtením entity
a jejím uložením, tedy na hranici operace, po abort() odmítnutého uložení nebo po dávce.
1. Odpojená reference#
Doctrine\ORM\ORMInvalidArgumentException:
A new entity was found through the relationship 'X#owner' that was not
configured to cascade persist operations
Při flush() vypadá odpojená entita jako nová entita bez kaskády.
Náprava: znovu připojit přes $em->getReference() těsně před uložením, nebo držet jen
ID a entitu načíst v operaci, která ji ukládá.
2. Líná kolekce inicializovaná po vyprázdnění#
- Načteš entitu s
includes; kolekce, která v nich není, zůstane líná. - Mapa se vyprázdní (hranice operace, dávka).
- Teprve teď iteruješ línou kolekci: děti jsou spravované, ale jejich zpětná reference ukazuje na odpojeného vlastníka.
flush()vyhodí stejnou hlášku jako v bodě 1.
includes musí pokrývat každou kolekci, které se dotkne cokoli spuštěné z operace. Kde to
nejde, dohydratuj kolekce explicitním „touch", dokud je vlastník ještě spravovaný.
3. Duplicitní instance#
While adding an entity of class …\OrderShipper with an ID hash of "1" to the
identity map, another object of class …\OrderShipper was already present for
the same ID.
Tatáž entita se do mapy dostala podruhé jinou cestou.
Kdo smí mapu vyprázdnit jinak#
Žádný jiný kód než UnitOfWorkReset (app/Core/Doctrine/Operations/UnitOfWorkReset.php) nesmí
v app/ volat clear() ani detach(). Hlídá to test UnitOfWorkResetBoundaryTest. Každá metoda
se jmenuje podle důvodu, aby šly v logu rozlišit:
| Metoda | Kdy |
|---|---|
abort() |
odmítnuté uložení nebo rollback; rozpracovaný graf se nesmí uložit později |
finishBatch() |
dlouhý běh dokončil dávku a zahazuje, co načetl |
resetBatch() |
fáze po zápisech přes DBAL začíná s čistou mapou |
discard() |
po rollbacku odpojí jen dané entity |
recoverAfterFailure(), reopenIfClosed() |
obnova po selhání flush() |
beginOperation(), endOperation() |
hranice kořenové operace; volá je jen příjemce |
Postup: píšu hook, který potřebuje cizí data#
- Načti všechno PŘED tím, než začneš skládat graf. Číselníky, nastavení a cizí klíče na začátku.
- Raw DBAL je legitimní náhrada pro jednoduchá čtení uvnitř rozestavěného grafu:
| Potřebuji | Náhrada |
|---|---|
| jeden řádek číselníku | raw DBAL dotaz přes $em->getConnection() |
| nastavení | SettingContext (v paměti, nesahá na DB) |
| cizí klíč do asociace | $em->getReference() |
| ověřit existenci | SELECT COUNT přes DBAL |
- Ověř uložením, ne čtením. Chyba se projeví až při
flush().
Pořadí ‚nejdřív čtení, pak stavba grafu' zůstává užitečné
Čtení graf už neodpojí, ale volání jiné operace uvnitř hooku (přes internalAction*)
s sebou nese vlastní pravidla. Je jednodušší načíst vše dopředu.
Postup: dostal jsem „A new entity was found through the relationship"#
- Nedívej se na místo, kde spadl
flush(). Výjimka vzniká až při ukládání; příčina je dřív: mezi načtením entity a uložením se mapa vyprázdnila. - Projdi cestu od načtení ke
flush()a hledej hranici operace: volání jinéinternalAction*, dávku (finishBatch()), odmítnuté uložení (abort()). - Podívej se na
includes. Chybí tam kolekce, kterou někdo iteruje až později? - Podívej se do
log/operations.log. ŘádekOPERATIONukazujeclear_abort,clear_batch,clear_reset,clear_recover,clear_operation,clear_otheradetacha vreset_reasonsdůvod.clear_otherje vyprázdnění mimoUnitOfWorkReset. - Teprve pak sáhni na
getReference()jako na náplast.
Postup: skript v příkazové řádce nebo cron#
Bez operace se mapa nevyprázdní nikdy, takže druhé find() téže entity po změně v databázi
zvenčí (raw SQL, jiný proces) vrátí starou instanci.
- Skript v
bin/obal:OperationRunner::cli($container, 'nazev-kroku', fn () => …). - Jeden logický krok (jeden region, jeden doklad) = jedna operace. Ven z kroku vracej hodnoty, ne entity.
- Akce Cronu obaluje
Cron\BasePresentersám; uvnitř akce nic dalšího nepřidávej. - Dlouhou dávku v cronu rozděl a po každé dávce zavolej
finishBatch(), jinak celá akce drží všechny načtené entity.
Log operací#
log/operations.log dostává souhrn OPERATION_REQUEST na požadavek a řádek OPERATION jen
u operace, která stojí za pozornost (params.1.operations.log: summary): outcome failed nebo
rejected, porušení kontraktu, clear_other různé od nuly, clear_abort / clear_reset /
clear_recover / detach nad nulou, nebo překročený práh logThreshold (ms: 250, sql: 50,
uowSize: 500). Hodnota verbose zapisuje každou operaci a log roste rychle (v E2E testech
stovky MB za hodinu). Log se nerotuje.
Změna parametrů vyžaduje smazat cache kontejneru
Rozhoduje se při kompilaci. Po změně operations.neon smaž temp/cache/nette.configurator.
Kam sáhnout#
| Chci | Kde |
|---|---|
| hranice a pravidla | app/Core/Doctrine/Operations/DoctrineOperationParticipant.php |
| všechna povolená vyprázdnění | app/Core/Doctrine/Operations/UnitOfWorkReset.php |
| otevření operace v Api | operation() v app/Core/Base/Api/BasePresenter.php |
| operace v Cronu a CLI | app/Core/Operations/OperationRunner.php |
| nastavení a log | config/Shared/operations.neon |
| hotový příklad obejití přes DBAL | app/UI/Api/Invoicer/Models/Generators/InvoiceNumberGenerator.php |
| lifecycle hooky | app/Core/Traits/Api/Models/Managers/SaveTrait.php |
Navazující kapitoly: Životní cyklus manageru · Hydrátory · Volání API · Pětivrstvý model