Read model a eventual consistency v CQRS
Read model v CQRS na Symfony 8: denormalizovaná tabulka mimo ORM (migrace, schema_filter), projektor na event busu a proč samotný upsert není idempotentní (last-write-wins, updated_at s mikrosekundami, DeduplicateMiddleware). Kdo událost odešle: synchronní projekce, fantomová a ztracená událost, DispatchAfterCurrentBusStamp. Okno zastaralosti v UI a HTTP (PRG, polling, read-your-writes, 202 s Retry-After), co musí rozhodovat write strana. Retry s jitterem, failure transport, monitoring a provoz workerů.
Přehrávač YouTube se načte až po kliknutí · otevřít na YouTube (nové okno) ↗
Přepis dílu komentář tak, jak zazní ve videu
404 po úspěšné objednávce
Modelová situace. Zákazník odešle objednávku a dostane potvrzení, že proběhla. Aplikace ho přesměruje na detail objednávky a ten vrátí čtyři sta čtyři.
Command přitom prošel a objednávka v databázi je. Jen projektor, který plní read model, ještě nedoběhl. O chvilku později vrátí stejná adresa dvě stě.
Tohle okno není chyba. Je to vlastnost asynchronní propagace a dá se s ním počítat, když víte, kde vzniká.
Denormalizovaná tabulka a projektor
Read strana má volnou ruku. Write model drží normalizaci kvůli konzistenci. Read model může jít opačně a data denormalizovat přesně do tvaru obrazovky.
Kapitola nabízí celou škálu, od přímého SQL přes DBAL až po Elasticsearch nebo Redis.
Nejčastěji se ale potkáte s denormalizovanou tabulkou pro jedinou obrazovku. V knize je to order dashboard, přehled objednávek s předpočítanými sloupci.
Má jeden provozní háček. Tahle tabulka není ORM entita a Doctrine o ní neví. Porovnání schématu ji proto navrhne zahodit.
Vzniká tedy ruční migrací a z porovnávání ji vyřadí schema filter v konfiguraci Doctrine. Každou novou projekci je potřeba do výčtu doplnit.
Všimněte si sloupce status. Objednávku, kterou agregát drží ve stavu Confirmed, dashboard ukazuje jako placed. Read model smí mít vlastní slovník, podle něj totiž nikdo o přechodech nerozhoduje.
Tabulku plní projektor. Je to obyčejný handler na event busu, který z událostí zapisuje řádky. Založení objednávky řeší upsert.
V knize ho spouští integrační událost z dílu patnáct. INSERT založí řádek, a když už existuje, ON CONFLICT ho přepíše.
Vypadá to idempotentně. Jenže asynchronní transport doručuje at least once, takže táž zpráva může dorazit znovu, třeba po opakování.
Modelově. Událost o založení přijde podruhé, až když je objednávka odeslaná. Upsert je last write wins a řádek vrátí zpátky na placed.
Nic nespadne. Dashboard jen začne tvrdit, že odeslaná objednávka je zase jen přijatá.
Oprava stojí na jedné podmínce. Řádek se přepíše, jen když je nová událost novější než to, co v něm leží. Stejnou podmínku nesou i UPDATE pro odeslání a storno.
Krátký odhad. Čas se zatím formátuje bez zlomků sekundy. Založení a odeslání spadnou do stejné vteřiny. Co ukáže dashboard?
Placed. Obě události mají stejný čas, podmínka menší než je nepravdivá a platný přechod se tiše zahodí.
Proto kniha formátuje čas s mikrosekundami a sloupec má přesnost na šest míst. Dvě události jednoho agregátu totiž do jedné vteřiny spadnou běžně.
DeduplicateMiddleware ze Symfony sedm tři zahodí druhé odeslání téže zprávy, dokud první čeká ve frontě. Opakované doručení už přijaté zprávy ani rebuild ale nezastaví, takže idempotenci v databázi nenahradí.
Kdo událost odešle
Projektor počítá s tím, že mu událost někdo doručí. Nejjednodušší cestu znáte z dílu deset bé. Po flushi se události vyzvednou přes releaseEvents a odejdou na event bus.
Synchronní projekce běží pod doctrine transaction v téže transakci jako zápis agregátu. Selže commit a vrátí se i řádek dashboardu. Pro kanonický routing knihy je to plnohodnotná volba.
Slabé místo přinese až asynchronní routing. Odeslání do fronty totiž není se zápisem atomické.
Krátký odhad. Middleware doctrine transaction je zapnutý, projekce běží asynchronně a commit selže. Co uvidí dashboard?
Objednávku, která neexistuje. Zpráva odešla do fronty ještě před commitem a projektor zpracoval událost, která se nestala.
Bez middlewaru, s odesláním až po commitu, hrozí opak. Proces spadne mezi commitem a odesláním a událost se ztratí.
Symfony umí odeslání pozdržet stampem DispatchAfterCurrentBusStamp. Zpráva pak odejde až po dokončení handleru. A když jeho middleware stojí před doctrine transaction, až po commitu.
Tím zmizí fantomová událost, okno mezi commitem a odesláním ale zůstává. Obě díry zavře až outbox, který událost uloží v téže transakci jako agregát. Na něj přijde řada v dílu patnáct.
Projekce jde navíc kdykoli postavit znovu. Nová tabulka, přehrát do ní události, přepnout dotazy a starou smazat. Potřebuje to ale zdroj událostí, Event Store nebo log zpráv. Bez Event Sourcingu třeba change data capture z write databáze.
Eventual consistency v praxi
Zpátky k úvodu. Při asynchronní propagaci existuje okno, kdy read model ještě nemá poslední zápis. Kapitola ho odhaduje na milisekundy až jednotky sekund.
Werner Vogels mu v roce dva tisíce osm říkal okno nekonzistence. Dokud propagace zůstává asynchronní, počítat s ním musí rozhraní.
Kapitola k tomu nabízí pět strategií, od optimistické aktualizace v prohlížeči přes polling až po synchronní projekci pro kritické cesty.
Nejlevnější je Post Redirect Get. PlaceOrderController po odeslání commandu přidá flash zprávu a přesměruje. Uživatel vidí potvrzení a read model má čas doběhnout.
Jen pozor. Když cílová stránka čte z asynchronního read modelu, samotné přesměrování okno nezavře. Tam přijde na řadu polling nebo synchronní projekce.
API klient potřebuje tvrdší záruku, read your writes. Tedy přečíst to, co právě zapsal. Odpověď na command proto nese verzi zápisu a klient ji pošle s dalším dotazem v hlavičce.
Čtecí endpoint porovná, kam se projekce dostala. Když zaostává, chvíli počká, desítky až stovky milisekund. Po limitu vrátí dvě stě dva s hlavičkou Retry-After a klient to zkusí znovu.
A proč ne tři sta čtyři? Ten říká, že je vaše cache platná. O datech, která ještě nedorazila, neříká nic.
Krátký odhad. Zákazník smí mít nejvýš deset objednávek denně. Stačí kontrola proti read modelu?
Nestačí. Read model může o poslední objednávky zaostávat a limit by se dal přečerpat. Kontrola patří write straně, v okamžiku commandu.
Stejně je to s finančními zůstatky a s unikátností e-mailu. Eventual consistency se týká zobrazení dat, doménová rozhodnutí se o read model neopírají.
Fronty, retry a dead letter queue
Zbývá provoz. Zpráva směrovaná na asynchronní transport se při dispatchi serializuje do fronty. Worker ji později vyzvedne a předá handleru.
Když handler selže, Messenger zprávu pošle znovu. Kniha dává transportu událostí tři opakování. První po sekundě, další po dvou a po čtyřech, se stropem šedesát sekund.
Klíč jitter k tomu přidá náhodný rozptyl. Bez něj by se po výpadku vrátily všechny zprávy naráz.
Po posledním pokusu jde zpráva na failure transport, do dead letter queue. Tam čeká na člověka. Nastavit ho jde i pro jednotlivý transport, takže projekce můžou mít vlastní frontu chyb.
Příkaz failed show s číslem zprávy ukáže výjimku. Po opravě ji failed retry pošle znovu.
Dead letter queue ale není odkladiště. Počet zpráv v ní hlídá alerting, třeba přes messenger stats. Co v ní leží, může znamenat read model rozjetý s write modelem.
Worker je dlouho běžící proces. Supervisor nebo systemd ho po pádu spustí znovu. Time limit a memory limit ho po čase nebo po zaplnění paměti ukončí, aby naběhl s čistým stavem.
Krátký odhad. Kniha přidává ještě failure limit pět. Proč se hodí právě u projekcí?
Worker se zastaví po páté zprávě, která selhala. Jinak by vadné nasazení protlačilo celou frontu do dead letter queue dřív, než si toho někdo všimne.
Projektor se testuje integračně s reálnou databází, i na dnešní opožděné doručení. Testům patří díl sedmnáct. A sáze, která kapitolu uzavírá, díl čtrnáct.
Otázky a shrnutí
Otázka na rozmyšlenou. Při rebuildu přehrajete všechny události znovu, jenže do stávající tabulky dashboardu. Co s řádky udělá podmínka na updated at?
Žádný existující řádek nepřepíše. Přehrané události jsou stejně staré nebo starší než to, co v řádcích leží, a podmínka menší než pro ně neplatí. I proto rebuild plní novou verzi tabulky.
A jedna z dílu jedenáct á. Command přesunete na asynchronní transport. Proč přestane fungovat autorizace postavená na tokenu?
Worker běží mimo HTTP požadavek a TokenStorage je prázdná. Command proto nese identitu aktéra, actorId, kterou do něj zapíše controller.
Takže v kostce. Read model je tabulka pro jednu obrazovku. Spravuje ji migrace a plní projektor.
Projektor musí přežít opakované i opožděné doručení. Samotný upsert na to nestačí.
Okno zastaralosti se řeší v rozhraní a v HTTP. Doménová rozhodnutí zůstávají na write straně.
Příští díl otevírá Event Sourcing. Agregát se v něm skládá z uložených událostí a read model z nich vzniká projekcí. Celou kapitolu o CQRS najdete v knize na ddd-v-symfony.katuscak.cz.