Kapitola 14 · Vzory · Ságy a Process Managery

Ságy a Process Managery

Ságy a Process Managery v DDD a Symfony 8 – implementace kompenzačních transakcí, choreografie i orchestrace dlouhotrvajících procesů pomocí Symfony Messenger. Včetně timeoutů, paralelních kroků a monitorování distribuovaných procesů.

Autor M. Katuščák
Doba čtení ≈ 40 min
Náročnost náročná
Publikováno · Aktualizováno ·
Obsah kapitoly

V předchozí kapitole jsme se zabývali Event Sourcingem – vzorem persistence, který ukládá stav jako sekvenci neměnných událostí. Ságy na tento koncept přirozeně navazují. Event Sourcing řeší persistenci uvnitř jednoho agregátu; ságy koordinují procesy napříč více agregáty a Bounded Contexts, které spolu komunikují prostřednictvím doménových událostí.

14.01 Proč potřebujeme ságy?

Jako ilustrativní příklad slouží typický e-shop: zákazník odešle objednávku a systém musí provést čtyři kroky napříč odlišnými Bounded Contexts:

  1. Ordering – vytvoření objednávky (agregát Order),
  2. Payment – stržení platby zákazníkovi (agregát Payment),
  3. Warehouse – rezervace zboží na skladě (agregát StockReservation),
  4. Shipping – vytvoření zásilky (agregát Shipment).

Každý z těchto kontextů má vlastní agregát, vlastní databázi (nebo alespoň vlastní tabulky se striktně oddělenou odpovědností) a vlastní invarianty, které musí chránit. Agregáty v různých Bounded Contexts nelze měnit v jedné databázové transakci – to by porušilo autonomii kontextů, jež je základním pilířem DDD. Jeden kontext nesmí sahat do databáze jiného kontextu; komunikace probíhá výhradně prostřednictvím zpráv (událostí a příkazů).

Proč nelze zabalit všechny čtyři kroky do jediné databázové transakce? Jednotlivé kontexty mohou běžet na různých serverech a používat různé databázové systémy (PostgreSQL pro objednávky, Redis pro skladové rezervace, externí platební bránu pro platby). Komunikují asynchronně přes frontu zpráv. Koncept atomické transakce se zde rozpadá.

Příklad selhání: systém úspěšně strhne platbu zákazníkovi (krok 2), ale při rezervaci skladu zjistí, že zboží není dostupné (krok 3 selže). Zákazník přišel o peníze, zboží nemá a systém je v nekonzistentním stavu. Bez mechanismu, který by tento stav detekoval a napravil, zůstane zákazník bez peněz i bez zboží – což je v produkčním systému nepřijatelné.

Řešení tohoto problému navrhli již v roce 1987 Hector Garcia-Molina a Kenneth Salem v článku Sagas. Místo jedné velké distribuované transakce rozdělili proces na sérii lokálních transakcí, z nichž každá má definovanou kompenzační akci. Pokud některý krok selže, systém provede kompenzační akce pro všechny předchozí úspěšné kroky – v opačném pořadí. Tento vzor se v DDD komunitě ustálil pod názvy Saga a Process Manager.

Citace: Garcia-Molina, H. & Salem, K., Sagas, ACM SIGMOD (1987); Vernon, V., Implementing Domain-Driven Design (2013), kap. 8.

V následujících sekcích si ukážeme dva základní přístupy ke koordinaci ság – choreografii a orchestraci – a jejich praktickou implementaci v Symfony 8 s využitím Symfony Messenger.

14.02 Kompenzační transakce

Kompenzační transakce je sémantické vrácení efektu předchozího kroku. Na rozdíl od technického rollbacku databázové transakce (který „vymaže“ změny, jako by se nikdy nestaly) je kompenzace plnohodnotná doménová operace. Má vlastní vedlejší efekty – notifikace, auditní záznamy, aktualizace stavů. Systém se nevrací do původního stavu bit po bitu, ale do takového, který je z doménového pohledu ekvivalentní situaci před provedením kompenzovaného kroku.

Pro náš e-shop scénář vypadá mapa akcí a jejich kompenzací následovně:

Akce Kompenzace Poznámka
ChargeCustomer RefundCustomer Zahrnuje notifikaci zákazníka
ReserveStock ReleaseStock Uvolnění rezervace, nikoliv smazání
CreateShipment CancelShipment Pouze do okamžiku odeslání

Kompenzace není přesný inverzní příkaz. Zatímco ChargeCustomer strhne peníze, kompenzační RefundCustomer nejenže vrátí peníze, ale navíc odešle zákazníkovi notifikaci o vrácení platby, zapíše záznam do auditního logu a může aktualizovat interní metriky. Každá kompenzace je samostatný příkaz s vlastní logikou, validací a vedlejšími efekty.

14.03 Choreografie

Choreografie je přístup ke koordinaci ságy, při němž neexistuje centrální koordinátor. Každý Bounded Context reaguje na události publikované jinými kontexty a na jejich základě provádí svůj krok procesu. Žádná služba neví o celém toku – každá zná pouze svou část a ví, na které události má reagovat.

FIG. 15.3-A Choreografie vs. orchestrace - kdo koordinuje ságu

V našem e-shop scénáři probíhá choreografická sága následovně: kontext Ordering publikuje událost OrderPlaced. Kontext Payment na ni reaguje, strhne platbu a publikuje PaymentSucceeded. Kontext Warehouse naslouchá události PaymentSucceeded, rezervuje zboží a publikuje StockReserved. Kontext Shipping reaguje na StockReserved a vytvoří zásilku, čímž publikuje ShipmentCreated. Celý tok vzniká emergentně z reakcí jednotlivých kontextů na události ostatních – bez centrálního řízení.

Hlavní výhodou choreografie je volné provázání (loose coupling) mezi kontexty. Každý reaguje pouze na události, které mu přicházejí, a o ostatních nic neví. Přidání nového kontextu (například Loyalty, který přidělí body za objednávku) je triviální: stačí vytvořit nový handler naslouchající OrderPlaced. Pro procesy o dvou až třech krocích vystačí s choreografií.

14.04 Limity choreografie

U procesů s pěti a více kontexty nebo s podmíněným větvením narazí choreografie na čtyři problémy, které se v menším měřítku skrývají. V produkci se tyto problémy projeví ve chvíli, kdy do toku přibude šestý kontext nebo větvení podle stavu.

1. Neviditelný tok procesu

Při choreografii neexistuje žádné jedno místo, kde by byl celý doménový proces popsán. Tok procesu je rozdrobený do desítek handlerů v různých kontextech. S pěti a více kontexty není možné vizualizovat kompletní tok. Nikdo nemá přehled o tom, které kroky po sobě následují, kde se proces větví a jaké jsou alternativní cesty při selhání. Vzniká fenomén, který se někdy označuje jako „distribuované špagety“ (distributed spaghetti) – analogie ke špagetovému kódu, ale rozloženému do celého systému.

2. Porušení Open-Closed Principle

Přidání nového kroku do procesu často vyžaduje úpravu existujícího kontextu. Například pokud chceme mezi platbu a sklad vložit krok „ověření proti podvodům“ (Fraud Detection), musíme změnit handler ve Warehouse. Místo události PaymentSucceeded musí naslouchat na FraudCheckPassed. Tím porušujeme Open-Closed Principle – stávající kód kontextu Warehouse je nutné upravit, aby fungoval s novým krokem. Při orchestraci by stačilo přidat krok do centrálního Process Manageru bez zásahu do existujících kontextů.

3. Obtížná diagnostika selhání

Když se proces „zasekne“, kde hledat příčinu? Každý kontext zná pouze svůj krok – neví, jaký je celkový stav procesu. Operátor musí ručně procházet logy všech kontextů, korelovat události podle orderId a rekonstruovat, kde přesně proces selhal. Neexistuje centrální dashboard, který by zobrazil: „Objednávka #42 – platba OK, sklad SELHÁNÍ, zásilka NESPUŠTĚNA.“ V produkčním prostředí s tisíci objednávkami denně je tento přístup neúnosný.

4. Chybějící timeout management

Kdo detekuje, že proces „visí“? Pokud kontext Payment strhne platbu, ale Warehouse nikdy nezareaguje (handler spadl, zpráva se ztratila ve frontě), kdo zjistí, že proces stojí? Každý kontext zná pouze svůj krok a nemá přehled o časových limitech celého procesu. V choreografii neexistuje přirozené místo pro definici globálního timeoutu – nikdo nehlídá, že celý proces od OrderPlaced po ShipmentCreated musí trvat maximálně 30 minut.

Všechny tyto problémy poukazují na jednu věc: u komplexních procesů potřebujeme centrální místo, které zná celý tok, řídí kroky, detekuje selhání a spouští kompenzace. Tímto centrálním místem je orchestrátor – Process Manager.

14.05 Orchestrace – Process Manager

V orchestraci celý doménový proces řídí jediná třída – tzv. Process Manager. Funguje jako stavový automat s definovanými stavy a přechody. V našem e-shop scénáři tuto roli plní OrderProcessManager. Přijímá události ze všech kontextů (Payment, Warehouse, Shipping) a na jejich základě rozhoduje, jaký příkaz vydat jako další krok. Tok není rozdrobený do desítek handlerů – celá logika procesu se soustředí do jedné třídy. Na jednom místě je viditelný kompletní tok od OrderPlaced po ConfirmOrder.

Následující diagram zobrazuje stavový automat procesu objednávky. Zelené šipky značí úspěšné přechody, červené selhání a oranžová cesta vede přes kompenzaci:

FIG. 15.1-A Stavový automat OrderProcessManager

Orchestrace přináší oproti choreografii několik výhod: celý doménový proces je popsán na jediném místě, takže vývojář okamžitě vidí kompletní tok od objednávky po potvrzení. Při debugování stačí zkontrolovat stav ságy v databázi a hned je jasné, ve kterém kroku proces stojí. Rozšíření procesu o nový krok (například Fraud Detection mezi platbu a sklad) znamená doplnit jednu metodu do Process Manageru a jeden nový stav do enumu. Existující kontexty se nemění.

14.06 Perzistence stavu ságy

Process Manager potřebuje perzistentní úložiště stavu, aby přežil restart workeru, nové nasazení aplikace i horizontální škálování na více instancí. Bez perzistence by pád workeru mezi kroky OrderPlaced a PaymentSucceeded znamenal ztrátu informace o tom, kde se proces nachází. Sága by zůstala navždy „viset“ bez možnosti dokončení nebo kompenzace. Stav ságy proto ukládáme do databáze jako Doctrine entitu.

Díky perzistenci stavu je obnova po selhání přímočará. Worker spadne mezi zpracováním OrderPlaced a příchodem PaymentSucceeded. Po restartu Messenger znovu doručí nedokončenou událost a Process Manager načte stav ságy z databáze. Okamžitě ví, že sága čeká na platbu (AwaitingPayment), a pokračuje od správného kroku. Metoda findStale() v repository navíc umožňuje periodicky detekovat zaseklé ságy, které se déle než stanovený práh neposunuly kupředu, a spustit pro ně kompenzaci nebo eskalaci.

Multi-worker Process Manager – co se rozpadne

Optimistic lock řeší konflikt na jedné instanci ságy. V produkci se stane něco složitějšího: stejná zpráva (např. PaymentCompleted z téže objednávky) dorazí do více worker instancí současně (Messenger numprocs > 1), nebo různé eventy z téže ságy dorazí ve špatném pořadí (Kafka partition balancing, RabbitMQ multiple consumers). Důsledky:

  • Race na vznik ságy. První OrderPlaced pro stejné orderId dorazí do dvou workerů současně. Oba volají findOrCreateSaga(orderId), oba vidí prázdný stav, oba vytvoří OrderSaga. UNIQUE constraint na order_id jednoho z nich zabije, druhý zůstane. Bez constraint → dvě paralelní ságy téhož orderu, koliduje to o stav.
  • Out-of-order events. PaymentCompleted dorazí dřív než OrderConfirmed, sága zatím není ve stavu „čeká na platbu“. Process Manager netuší, co s ní – buď event zahodí (bug v doméně), nebo ho zařadí do pending fronty pro pozdější zpracování (komplexní stavový automat).
  • Kompenzační závody. Sága rozhodne Compensate, vyšle RefundPayment, a zároveň dorazí pomalá PaymentCompleted z jiného workeru. Druhá zpráva může resetovat stav ságy zpět na Confirmed, ale Refund už běží – peníze odešly i přijdou.

Standardní obrana proti všem třem:

Tři stavební prvky, které zde fungují společně:

  • UNIQUE constraint na order_id zabrání duplicitnímu vzniku ságy. Druhý INSERT vyhodí UniqueConstraintViolationException, handler ji zachytí a načte existující ságu místo vytvoření nové.
  • processedEventIds v entitě drží seznam již zpracovaných event ID. Stejný event přijde dvakrát → druhé volání skončí na guardu. To je „inbox per saga“ – paralela Idempotent Inbox z Outbox kapitoly.
  • State machine guard odmítne out-of-order event. Buď ho zahodí (idempotentně), nebo ho zařadí do pending events sloupce pro pozdější aplikaci.

Distributed deadlock mezi ságami

Klasický dvouagregátový deadlock přes Doctrine pessimistic lock: sága A drží lock na Order#1 a žádá o Inventory#42; sága B drží lock na Inventory#42 a žádá o Order#1. Postgres deadlock detector po cca 1 s jednu z transakcí zabije, ale do té doby čeká celý connection pool a stojí workers.

S eventual consistency (Vernonovo „eventual consistency mimo hranici agregátu“, viz Návrh agregátu) deadlock nemůže nastat na úrovni databáze – každý krok ságy je samostatná transakce na jeden agregát. Jiný typ deadlocku ale možný je: logický cycle deadlock v sáze samotné.

Příklad: sága OrderProcess čeká na PaymentSettled. Sága RefundProcess (pro storno) čeká na OrderCancelled. Pokud kompenzace způsobí storno objednávky a zároveň zrušení refundu, obě ságy čekají na sebe a žádná nedokončí.

Recovery z nekonzistentního stavu ságy

Sága může skončit v nekonzistentním stavu z legitimních příčin: deployment během transakce, OOM kill v polovině compensation kroku, schema migration změnila tvar state JSONu. Operátor potřebuje nástroje:

  • Read-only inspekce. CLI command app:saga:show <id>, který vypíše current state, pending events, processed event IDs, count of attempts. Plus link do Grafany na sagu.
  • Manual transition. CLI command app:saga:force-transition <id> <to> s povinným --reason="...". Aktualizuje status, zapíše audit log, invaliduje pending events. Jen pro operátory, ne automatický recovery – manual transition je signál, že sága má bug nebo doména má neošetřený scénář.
  • Replay od checkpointu. Pokud je sága idempotentní (a měla by být – viz výše), smazání saga state + replay všech jejích eventů z outbox/event store obnoví správný stav. Vyžaduje tracking správného starting eventu (typicky OrderPlaced event ID).

14.07 Implementace v Symfony Messenger

Předchozí sekce ukázaly Process Manager (orchestrátor) a perzistenci stavu ságy. Nyní propojíme obě části s Symfony Messenger – asynchronním message busem, který zajistí spolehlivé doručování událostí a příkazů mezi kontexty. Základní konfigurace Messenger busů je popsána v kapitole CQRS – Symfony Messenger. Zde se zaměříme na specifika pro ságy: oddělené transporty pro události a příkazy a retry strategie, bez kterých dlouhotrvající procesy ztrácejí zprávy při běžných výpadcích.

Celý tok funguje následovně: agregát Order v kontextu Ordering publikuje událost OrderPlaced na event bus. Messenger ji podle konfigurace routingu odešle do transportu async_events. Worker naslouchající na tomto transportu zprávu vyzvedne a předá ji OrderProcessManager, který ji zpracuje metodou onOrderPlaced(). Ta uloží stav ságy a dispatchne příkaz ChargeCustomer na command bus. Messenger tento příkaz routuje do transportu async_commands, kde ho vyzvedne handler v kontextu Payment. Po úspěšném zpracování Payment publikuje PaymentSucceeded – a cyklus se opakuje pro další krok procesu.

Podrobnější informace o asynchronním zpracování zpráv, konfiguraci transportů a retry strategiích najdete v kapitole CQRS – asynchronní zpracování.

14.08 Timeouty a deadliny

Co se stane, když událost PaymentSucceeded nikdy nedorazí? Síťový výpadek, nedostupnost platební brány, ztráta zprávy ve frontě – v distribuovaném systému musíte vždy počítat s tím, že odpověď nepřijde. Bez explicitního timeout mechanismu sága zůstane navždy ve stavu AwaitingPayment a objednávka se nikdy nedokončí ani nezruší. Proto potřebujeme timeout check – odložený příkaz, který po uplynutí stanovené doby zkontroluje, zda se sága posunula dál, a pokud ne, spustí kompenzaci.

Timeout check naplánujeme přímo v Process Manageru při zpracování události OrderPlaced. Messenger nabízí DelayStamp, který zprávu podrží v transportu po zadanou dobu a teprve poté ji doručí workeru:

14.09 Kompenzační strategie v praxi

Když krok ságy selže, máme dvě základní strategie, jak situaci řešit. Volba závisí na povaze chyby – je přechodná (síťový výpadek, dočasná nedostupnost služby), nebo trvalá (nedostatek prostředků na účtu, zboží vyprodáno)?

Forward recovery (retry)

Při přechodných chybách je nejjednodušší strategií opakování – pokus o provedení stejného kroku znovu. Symfony Messenger nabízí vestavěnou retry strategii s exponenciálním backoffem, kterou jsme konfigurovali v sekci 7. Worker automaticky opakuje selhané zprávy podle nastavení max_retries, delay a multiplier. Tento přístup je vhodný, když věříme, že problém je dočasný a opakování může uspět.

Backward recovery (kompenzace)

Při trvalých chybách (selhání s doménovou příčinou) musíme spustit kompenzaci – vrátit systém do konzistentního stavu provedením kompenzačních akcí v opačném pořadí dokončených kroků. Kompenzace je sémantická, nikoli technická. Neděláme DELETE FROM payments – místo toho dispatchujeme nový doménový příkaz RefundCustomer, který vytvoří novou transakci (refund). Každá kompenzační akce je plnohodnotná doménová operace s vlastními pravidly a událostmi.

FIG. 15.9-A Kompenzační flow - rollback ságy v opačném pořadí

Podrobnější informace o Dead Letter Queue, retry strategiích a zpracování chyb v Messenger najdete v kapitole CQRS – zpracování chyb.

14.10 Paralelní kroky

Dosud jsme uvažovali sériové provádění kroků – jeden po druhém. V praxi však některé kroky na sobě nezávisí a mohou běžet současně. Například po úspěšné platbě chceme zároveň rezervovat zboží na skladě a vygenerovat fakturu. Obě operace jsou nezávislé – výsledek jedné neovlivňuje druhou. Paralelním zpracováním zkrátíme celkovou dobu trvání ságy.

Princip: sága dispatchuje oba příkazy současně a přejde do stavu AwaitingStockAndInvoice. V kontextu si uchovává dva příznaky (stockReserved a invoiceCreated). Teprve když oba dorazí jako splněné, sága pokračuje dalším krokem – vytvořením zásilky. Tomuto vzoru se říká synchronizační bariéra (synchronization barrier).

14.11 Monitoring a observabilita

Bez monitoringu se zpráva ztratí ve frontě, stav ságy zamrzne a nikdo si ničeho nevšimne, dokud si zákazník nestěžuje. Produkční sága proto potřebuje vědět, které instance právě běží, které se zasekly a které selhaly. Dva nástroje, které tuto viditelnost zajišťují: korelační ID pro trasování a detekce zaseklých ság.

Korelační ID

Každá zpráva v jedné sáze nese stejné korelační ID – typicky orderId. Díky němu můžete v logu vyfiltrovat všechny zprávy patřící ke konkrétní objednávce a sledovat celý průběh procesu od začátku do konce. Více o korelačních identifikátorech najdete v glosáři.

Detekce zaseklých ság

I při nejlepším návrhu se stane, že zpráva se ztratí, worker spadne nebo externí služba přestane odpovídat. Proto potřebujete cron/scheduled command, který pravidelně kontroluje, zda některá sága nezůstala příliš dlouho v mezistavech:

Podrobnosti o implementaci middleware v Symfony Messenger najdete v kapitole CQRS – sekce middleware.

14.12 Testování ság

Ságy koordinují složité vícekrokové procesy napříč Bounded Contexts. Chyba v přechodové logice nebo v kompenzacích se projeví až v produkci – stržená platba bez doručeného zboží, duplikované zásilky a podobně. Testujeme na třech úrovních.

Unit testy stavového automatu

Nejdůležitější úroveň: testujeme samotný Process Manager izolovaně od infrastruktury. Místo skutečného message busu použijeme spy implementaci, která zaznamenává dispatchované příkazy, a místo databáze in-memory repozitář:

Další vzory pro testování doménové logiky, agregátů a event handlerů najdete v kapitole Testování DDD aplikací.

Časté otázky

Jaký je rozdíl mezi Ságou a Process Managerem?

Sága je obecný pojem pro dlouhotrvající transakci napříč více službami, rozdělenou na sérii lokálních transakcí propojených kompenzacemi. Process Manager je konkrétní implementační styl ságy – centralizovaná komponenta s vlastním stavem, která orchestruje kroky zasíláním příkazů a reaguje na přicházející události. Tyto dvě osy (Sága/Process Manager vs. choreografie/orchestrace) jsou ortogonální: sága může být choreografická i orchestrovaná, Process Manager je vždy orchestrátor. V některé literatuře (Hohpe & Woolf, Richardson) se ovšem pojmem sága myslí spíš choreografická varianta, Process Managerem orchestrátor – terminologii je proto vhodné v každém zdroji ověřit. Rozbor rozdílů v sekci Orchestrace – Process Manager.

Choreografie, nebo orchestrace – kdy zvolit co?

Choreografie, kde služby reagují na události publikované ostatními, se hodí pro krátké procesy o dvou až třech krocích, kde je spojení mezi službami volné a globální stav není kritický. Orchestrace přes Process Manager je vhodnější pro složitější procesy s rozhodovací logikou, časovými limity nebo nutností centralizovaně znát stav běhu. Pro procesy přes více než tři kroky nebo s podmínkami je orchestrace obvykle udržovatelnější. Rozhodovací kritéria v sekci Limity choreografie.

Jak implementovat kompenzační transakce v Symfony?

Kompenzace je samostatná operace nebo command handler, který vrací systém do stavu před selhaným krokem – například CancelPayment jako protějšek AuthorizePayment. V Messenger sáze se kompenzace spouští, když příchozí událost signalizuje selhání některého z pozdějších kroků. Kompenzační příkazy musí být idempotentní a tolerantní k situaci, že kompenzovaný krok nikdy neproběhl. Ne každou operaci lze technicky vrátit, proto se někdy kompenzuje jiným způsobem. Praktický příklad v sekci Kompenzační strategie v praxi.

Jak zajistit idempotenci ságy při opakovaném doručení událostí?

Messenger může stejnou zprávu doručit vícekrát – při selhání workera nebo přebalení na retry queue – takže handler musí opakované zpracování bezpečně ignorovat. Standardní řešení jsou dvě: jedinečný identifikátor zprávy uložený do tabulky zpracovaných ID, nebo stavový automat ságy, který u každého kroku kontroluje, zda už není ve stavu „dokončeno“. Obě techniky brání duplicitnímu publikování příkazů i duplicitním kompenzacím. Podrobný rozbor v sekci Implementace v Symfony Messenger.

Má se sága obsluhovat přes Command Bus, nebo Event Bus?

Obojí, s jasně rozdělenou rolí. Události na Event Busu spouštějí reakce ságy – informují, že se něco stalo, a sága na ně navazuje. Příkazy na Command Busu sága sama vydává, aby řídila další kroky. Typická smyčka má tvar: příchozí event → Process Manager → odchozí command → handler → nový event. Nikdy se nezaměňuje: event nic nepřikazuje, command nic neoznamuje. Viz sekci Implementace v Symfony Messenger.