Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Identity mapa a hranice operace

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 EntityManager otevř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() ani findAll() 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í#

  1. Načteš entitu s includes; kolekce, která v nich není, zůstane líná.
  2. Mapa se vyprázdní (hranice operace, dávka).
  3. Teprve teď iteruješ línou kolekci: děti jsou spravované, ale jejich zpětná reference ukazuje na odpojeného vlastníka.
  4. 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#

  1. Načti všechno PŘED tím, než začneš skládat graf. Číselníky, nastavení a cizí klíče na začátku.
  2. 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
  1. 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"#

  1. 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.
  2. Projdi cestu od načtení ke flush() a hledej hranici operace: volání jiné internalAction*, dávku (finishBatch()), odmítnuté uložení (abort()).
  3. Podívej se na includes. Chybí tam kolekce, kterou někdo iteruje až později?
  4. Podívej se do log/operations.log. Řádek OPERATION ukazuje clear_abort, clear_batch, clear_reset, clear_recover, clear_operation, clear_other a detach a v reset_reasons důvod. clear_other je vyprázdnění mimo UnitOfWorkReset.
  5. 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.

  1. Skript v bin/ obal: OperationRunner::cli($container, 'nazev-kroku', fn () => …).
  2. Jeden logický krok (jeden region, jeden doklad) = jedna operace. Ven z kroku vracej hodnoty, ne entity.
  3. Akce Cronu obaluje Cron\BasePresenter sám; uvnitř akce nic dalšího nepřidávej.
  4. 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

Nastavení cookies