Ságy a Process Managery
Ságy a Process Managery v Symfony 8: kompenzační transakce, choreografie a orchestrace dlouho běžících procesů přes Messenger. Včetně timeoutů, paralelních kroků a monitorování.
Obsah kapitoly
Event Sourcing z předchozí kapitoly řeší persistenci uvnitř jednoho agregátu. Ságy koordinují procesy přes více agregátů a Bounded Contexts, které spolu komunikují doménovými událostmi.
14.01 Proč potřebujeme ságy?#
Ilustrativní příklad je typický e-shop: zákazník odešle objednávku a systém musí provést čtyři kroky ve čtyřech různých Bounded Contexts:
- Ordering – vytvoření objednávky (agregát
Order), - Payment – stržení platby zákazníkovi (agregát
Payment), - Warehouse – rezervace zboží na skladě (agregát
StockReservation), - 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 z různých Bounded Contexts se v jedné databázové transakci nemění, protože by to porušilo autonomii kontextů. Jeden kontext nesahá do databáze jiného; komunikuje výhradně zprávami (událostmi a příkazy).
Kontexty navíc 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. Atomická transakce přes ně neexistuje.
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ý stav detekuje a napraví, se situace sama nevyřeší.
Základní tvar řešení popsali už v roce 1987 Hector Garcia-Molina a Kenneth Salem v článku Sagas. Ságou tam nazývají dlouho běžící transakci rozloženou na sérii dílčích transakcí, z nichž každá má definovanou kompenzační akci. Selže-li některý krok, systém provede kompenzace všech předchozích úspěšných kroků – v opačném pořadí.
Co sága znamenala v roce 1987
Původní motivace byla jiná než dnešní. Autoři neřešili distribuci, ale zámky: dlouhá transakce drží zdroje a krátké transakce za ní stojí frontu. Prostředím je jediná centralizovaná databáze a dílčí transakce jsou obyčejné ACID transakce nad týmž systémem. Koordinaci obstarává komponenta Saga Execution Component, která čte transakční log a po pádu dohledá poslední nezkompenzovanou transakci. Jde tedy o orchestrátor. Choreografie, kde tok vzniká emergentně z reakcí účastníků, v článku vůbec není.
Jedno pozorování z článku přežilo beze změny: ostatní transakce mohou vidět efekty částečně provedené ságy. Když běží kompenzace, nikdo neinformuje ani neruší transakce, které mezitím stihly přečíst výsledek kompenzovaného kroku. Odtud pochází dnešní formulace, že sága nemá izolaci (viz Izolace ság).
Dnešní distribuovaná sága tedy nese jméno staršího vzoru, ale řeší jiný problém: autonomii služeb s oddělenými databázemi. Druhý kořen leží v Enterprise Integration Patterns, kde Process Manager drží stav posloupnosti a podle mezivýsledků rozhoduje o dalším kroku. Třetí přidal Udi Dahan, který v roce 2007 pro NServiceBus opustil pojem workflow ve prospěch ságy jako stavového obsluhovače zpráv s korelací a timeouty. Kompenzace v jeho pojetí skoro nefigurují; ty jsou Richardsonova linie.
Kterou konvenci kniha používá
Výsledkem jsou tři neslučitelné definice, které dnes žijí vedle sebe. Tým Microsoft patterns & practices termín „sága“ v průvodci CQRS Journey záměrně opustil a mluví jen o Process Manageru s odkazem na starší a odlišný význam toho slova. Navrhuje také dělicí čáru, kterou praxe nepřevzala: Process Manager routuje zprávy uvnitř jednoho Bounded Contextu, sága řídí proces přes hranice kontextů. Richardson naopak ságou nazývá obojí a orchestraci bere jako implementační detail. Třetí čára vede podle způsobu koordinace: sága jede na událostech a kompenzacích, Process Manager překládá události na příkazy.
Kniha se drží Richardsonova pojetí s jedním upřesněním. „Sága“ je zastřešující pojem pro dlouhotrvající proces rozložený na lokální transakce s kompenzacemi, ať ho řídí choreografie, nebo orchestrace. „Process Manager“ je orchestrační komponenta s perzistentním stavem, která takovou ságu řídí; ukazuje ji sekce 14.05. U cizího zdroje se vyplatí ověřit, kterou z konvencí používá.
Citace: Garcia-Molina, H. & Salem, K., Sagas, ACM SIGMOD (1987); Hohpe, G. & Woolf, B., Enterprise Integration Patterns (2003); Dahan, U., No more workflow for nServiceBus – please welcome the Saga (2007); Microsoft patterns & practices, CQRS Journey (2012), Reference 6: A Saga on Sagas; Vernon, V., Implementing Domain-Driven Design (2013), kap. 4 a 13; Richardson, C., Microservices Patterns (2018), kap. 4.
Ságu lze koordinovat dvěma způsoby: choreografií a orchestrací. Implementace v Symfony 8 stojí na 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ý z doménového pohledu odpovídá stavu před kompenzovaným krokem.
Akce e-shopu a jejich kompenzace:
| 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 peníze
vrátí a k tomu odešle zákazníkovi notifikaci, 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.
Kdy proces ságou být nemůže
Ne každý vícekrokový proces snese rozdělení na kompenzovatelné kroky. Garcia-Molina se Salemem uvádějí protipříklad, který funguje dodnes jako test: převod peněz mezi účty. První krok částku odepíše a podrží ji v lokální proměnné, poslední ji připíše. V mezidobí peníze v databázi nejsou nikde. Takový mezistav není neúplný, ale pro doménu nečitelný: audit spuštěný mezi kroky nenajde všechny prostředky.
Rozdíl mezi „neúplným“ a „nečitelným“ mezistavem je použitelné návrhové kritérium. Objednávka se strženou platbou a nerezervovaným zbožím je neúplná: doména pro ten stav má jméno a operátor s ním umí pracovat. Peníze, které nejsou na žádném účtu ani v žádné evidenci, jméno nemají. Nedá-li se pro mezistav ságy napsat srozumitelný stav v Ubiquitous Language, vzor v této podobě nesedí.
Tentýž článek nabízí i východisko. Uloží-li se odepsaná částka do databáze jako „peníze na cestě“ (funds in transit), mezistav dostane jméno a převod ságou být může. Transakce, které potřebují vidět všechny peníze, pak musí s touto evidencí počítat, a proto ji autoři doporučují navrhnout od začátku, ne dolepit dodatečně. Tak převod řeší kapitola o návrhu agregátu. Když takový stav doména pojmenovat nechce, kroky patří do jedné transakce nad jedním agregátem.
14.03 Choreografie#
Při choreografii neexistuje centrální koordinátor. Každý Bounded Context reaguje na události publikované jinými kontexty a podle nich provádí svůj krok procesu. O celém toku neví žádná služba: každá zná jen svou část a události, na které má reagovat.
V e-shopu probíhá choreografická sága takto: kontext Ordering
publikuje událost OrderPlaced (přes hranici jako OrderPlacedIntegrationEvent). 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í.
Handlery 2 a 3 i Process Manager dál v kapitole pro stručnost konzumují přímo doménové
události cizích kontextů (PaymentSucceeded, StockReserved). V produkci by je Payment
a Warehouse publikovaly jako integrační události přes outbox, stejně jako Ordering
publikuje OrderPlacedIntegrationEvent v kapitole
Outbox Pattern.
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)
nevyžaduje zásah do existujícího kódu: stačí nový handler naslouchající
OrderPlacedIntegrationEvent. Dokud je posloupnost kroků známá dopředu a běží lineárně, choreografie
stačí.
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é v menším měřítku nejsou vidět.
1. Neviditelný tok procesu
Při choreografii není celý doménový proces popsaný na žádném jednom místě. Tok je rozdrobený do desítek handlerů v různých kontextech. 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í. Výsledku se říká „distribuované špagety“ (distributed spaghetti).
2. Porušení Open-Closed Principle
Nový krok procesu často vyžaduje úpravu existujícího kontextu. Vložit mezi platbu
a sklad „ověření proti podvodům“ (Fraud Detection) znamená změnit handler ve Warehouse:
místo PaymentSucceeded musí naslouchat události FraudCheckPassed. Stávající kód se
kvůli novému kroku mění, a to porušuje Open-Closed Principle. 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“, příčina se hledá těžko. Každý kontext zná jen svůj krok,
celkový stav procesu nezná nikdo. 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.“
Při tisících objednávek denně je takové pátrání neúnosné.
4. Chybějící timeout management
Kontext Payment strhne platbu, ale Warehouse nikdy nezareaguje: handler spadl, zpráva
se ztratila ve frontě. Že proces stojí, nezjistí nikdo. Časové limity celého procesu
nezná žádný z kontextů a choreografie pro globální timeout nemá přirozené místo –
například pro pravidlo, že proces od OrderPlaced po ShipmentCreated smí trvat
nejvýše 30 minut.
Komplexní proces proto potřebuje centrální místo, které zná celý tok, řídí kroky, detekuje selhání a spouští kompenzace. Tuto roli plní orchestrátor – Process Manager.
14.05 Orchestrace – Process Manager#
V orchestraci celý doménový proces řídí jediná třída, Process Manager. Funguje
jako stavový automat s definovanými stavy a přechody. V e-shopu tuto roli plní
OrderProcessManager: přijímá události ze všech kontextů (Payment, Warehouse, Shipping)
a podle nich rozhoduje, jaký příkaz vydat jako další krok. Kompletní tok od OrderPlaced
po ShipOrder je vidět na jednom místě.
Diagram ukazuje stavový automat procesu objednávky. Zelené šipky značí úspěšné přechody, červené selhání a oranžová cesta vede přes kompenzaci:
Objednávka projde třemi stavy: do Confirmed ji dostane už továrna
(placeWithItems() z kapitoly Outbox Pattern
dostává kompletní objednávku, takže Draft opouští hned),
odtud MarkOrderPaid do Paid a ShipOrder do Shipped. Sága sama stav agregátu
nemění, jen posílá příkazy a čeká na události.
Právě zde se pozná, jestli je proces domyšlený: chybí-li jediný příkaz, sága doběhne
do Completed a objednávka zůstane rozpracovaná. Nikde nic nespadne, jen se stavy rozejdou.
Test procesu proto kontroluje i stav agregátu, nejen stav ságy.
Na rozdíl od choreografie popisuje celý doménový proces jediné místo, takže vývojář
vidí kompletní tok od objednávky po expedici. Při debugování stačí zkontrolovat stav
ságy v databázi a je jasné, ve kterém kroku proces stojí. Rozšíření o nový
krok (například Fraud Detection mezi platbu a sklad) znamená nový stav v enumu,
novou metodu pro FraudCheckPassed a úpravu onPaymentSucceeded, která místo
rezervace skladu nově vydá příkaz pro kontrolu podvodů. Celá změna zůstává
v Process Manageru a jeho enumu stavů. Kontexty Warehouse ani Payment se neupravují.
Události kroků
Každý krok ohlásí výsledek neměnnou událostí. Ta nese kromě eventId jen primitivy,
protože cestuje mezi kontexty a příjemce nemá znát hodnotové objekty cizího kontextu.
1<?php2 3declare(strict_types=1);4 5// --- src/Payment/Domain/Event/ ---6namespace App\Payment\Domain\Event;7 8use Symfony\Component\Uid\Uuid;9 10// Každá kroková událost nese vlastní identitu. Bez ní nejde zapnout11// idempotenci ságy ze sekce 14.06 – guard se nemá čeho chytit12// a opakované doručení provede přechod podruhé.13final readonly class PaymentSucceeded14{15 public function __construct(16 public Uuid $eventId,17 public string $orderId,18 public string $transactionId = '',19 ) {}20}21 22final readonly class PaymentFailed23{24 public function __construct(25 public Uuid $eventId,26 public string $orderId,27 public string $failureReason,28 ) {}29}30 31final readonly class RefundSucceeded32{33 public function __construct(34 public Uuid $eventId,35 public string $orderId,36 public string $refundId = '',37 ) {}38}39 40final readonly class RefundFailed41{42 public function __construct(43 public Uuid $eventId,44 public string $orderId,45 public string $failureReason,46 ) {}47}48 49// --- src/Warehouse/Domain/Event/ ---50namespace App\Warehouse\Domain\Event;51 52use Symfony\Component\Uid\Uuid;53 54final readonly class StockReserved55{56 public function __construct(57 public Uuid $eventId,58 public string $orderId,59 public string $reservationId = '',60 ) {}61}62 63final readonly class StockReservationFailed64{65 public function __construct(66 public Uuid $eventId,67 public string $orderId,68 public string $failureReason,69 ) {}70}71 72// --- src/Shipping/Domain/Event/ ---73namespace App\Shipping\Domain\Event;74 75use Symfony\Component\Uid\Uuid;76 77final readonly class ShipmentCreated78{79 public function __construct(80 public Uuid $eventId,81 public string $orderId,82 public string $shipmentId = '',83 ) {}84}
Všechny nesou orderId, korelační klíč, podle kterého si Process Manager
najde svou ságu. Bez něj by událost nešlo přiřadit k běžícímu procesu.
Handlery kroků žijí v cizích kontextech
Process Manager příkazy jen rozesílá. Vykonává je vždy handler v tom kontextu, kterému
krok patří – a ten o existenci ságy nic neví. Výpis ukazuje jeden z nich; ReserveStockHandler
i CreateShipmentHandler mají stejný tvar, jen jiný agregát a jinou výslednou událost:
1<?php2 3declare(strict_types=1);4 5namespace App\Payment\Application\Handler;6 7use App\Payment\Application\Command\ChargeCustomer;8use App\Payment\Domain\Event\PaymentFailed;9use App\Payment\Domain\Event\PaymentSucceeded;10use App\Payment\Domain\PaymentGateway;11use Symfony\Component\DependencyInjection\Attribute\Target;12use Symfony\Component\Messenger\Attribute\AsMessageHandler;13use Symfony\Component\Messenger\MessageBusInterface;14use Symfony\Component\Uid\Uuid;15 16#[AsMessageHandler(bus: 'command.bus')]17final readonly class ChargeCustomerHandler18{19 public function __construct(20 private PaymentGateway $gateway,21 #[Target('event.bus')]22 private MessageBusInterface $eventBus,23 ) {}24 25 public function __invoke(ChargeCustomer $command): void26 {27 // Handler o sáze neví. Jen vykoná krok a oznámí výsledek;28 // co bude dál, rozhodne Process Manager.29 try {30 $transactionId = $this->gateway->charge(31 $command->customerId,32 $command->amountCents,33 );34 } catch (\RuntimeException $e) {35 $this->eventBus->dispatch(new PaymentFailed(36 eventId: Uuid::v7(),37 orderId: $command->orderId,38 failureReason: $e->getMessage(),39 ));40 41 return;42 }43 44 $this->eventBus->dispatch(new PaymentSucceeded(45 eventId: Uuid::v7(),46 orderId: $command->orderId,47 transactionId: $transactionId,48 ));49 }50}
Selhání se hlásí událostí, ne výjimkou. Výjimka by skončila v retry smyčce Messengeru
a sága by se o neúspěchu nedozvěděla. Zůstala by viset ve stavu AwaitingPayment,
dokud ji nevypne timeout.
PaymentGateway je port do platební brány; pro ostatní kroky platí totéž se StockService
a ShippingService. Doména zná jen rozhraní, konkrétní adaptér přijde z infrastruktury:
1<?php2 3declare(strict_types=1);4 5namespace App\Payment\Domain;6 7interface PaymentGateway8{9 /**10 * @return string identifikátor transakce11 * @throws \RuntimeException když platba neprojde12 */13 public function charge(string $customerId, int $amountCents): string;14 15 public function refund(string $transactionId, int $amountCents): string;16}
Na konkrétním adaptéru ukázky nestojí: za rozhraním může být HTTP klient platební
brány, v testech a při rozbíhání ukázek pár řádků v paměti. Právě proto rozhraní
existuje. Jde o zjednodušený výřez s primitivy, který stačí sáze. Port s doménovými
typy (Money, PaymentToken, PaymentId) ukazuje kapitola
DDD v praxi – kde to bolí. Pro kompenzační větve stačí adaptér s přepínačem, který nechá krok vždy selhat.
1<?php2 3declare(strict_types=1);4 5namespace App\Payment\Infrastructure;6 7use App\Payment\Domain\PaymentGateway;8use Symfony\Component\Uid\Uuid;9 10final readonly class InMemoryPaymentGateway implements PaymentGateway11{12 // Přepínač pro ruční zkoušku kompenzační větve; hodnotu dodá kontejner13 // (wiring o kus níž). V ostrém adaptéru ho nahradí odpověď brány.14 public function __construct(15 private bool $alwaysFails = false,16 ) {}17 18 public function charge(string $customerId, int $amountCents): string19 {20 if ($this->alwaysFails) {21 throw new \RuntimeException('Payment declined.');22 }23 24 return (string) Uuid::v7();25 }26 27 public function refund(string $transactionId, int $amountCents): string28 {29 return (string) Uuid::v7();30 }31}
StockService a ShippingService mají stejnou stavbu. Výpis je zde proto, že bez nich se
sága zastaví po první platbě a kompenzační větev STOCK_FAILS=1 nejde vůbec spustit:
1<?php2 3declare(strict_types=1);4 5// --- src/Warehouse/Domain/StockService.php ---6namespace App\Warehouse\Domain;7 8interface StockService9{10 /** @throws \RuntimeException když zboží není skladem */11 public function reserve(string $orderId): void;12 13 public function release(string $orderId): void;14}15 16// --- src/Shipping/Domain/ShippingService.php ---17namespace App\Shipping\Domain;18 19interface ShippingService20{21 /** @return string identifikátor zásilky */22 public function create(string $orderId): string;23 24 public function cancel(string $shipmentId): void;25}26 27// --- src/Warehouse/Infrastructure/InMemoryStockService.php ---28namespace App\Warehouse\Infrastructure;29 30use App\Warehouse\Domain\StockService;31use Symfony\Component\Uid\Uuid;32 33final readonly class InMemoryStockService implements StockService34{35 public function __construct(private bool $alwaysFails = false) {}36 37 public function reserve(string $orderId): void38 {39 if ($this->alwaysFails) {40 throw new \RuntimeException('Out of stock.');41 }42 }43 44 public function release(string $orderId): void45 {46 }47}48 49// --- src/Shipping/Infrastructure/InMemoryShippingService.php ---50namespace App\Shipping\Infrastructure;51 52use App\Shipping\Domain\ShippingService;53use Symfony\Component\Uid\Uuid;54 55final readonly class InMemoryShippingService implements ShippingService56{57 public function create(string $orderId): string58 {59 // Rozhraní vrací řetězec, ne hodnotový objekt: identifikátor60 // putuje v události přes hranici kontextu. ShipmentId z něj61 // sestaví až ShipOrderHandler v Orderingu.62 return (string) Uuid::v7();63 }64 65 public function cancel(string $shipmentId): void66 {67 }68}
Zapojení do kontejneru patří do services.yaml; bez něj mají přepínače výchozí false
a kompenzační větev se nespustí, ať do prostředí napíšete cokoli:
1parameters:2 # Výchozí hodnoty pro případ, že .env proměnné nezná – do souboru je3 # zapisuje až kapitola Praktické příklady. Bez nich kontejner spadne4 # na „Environment variable not found“.5 env(PAYMENT_FAILS): '0'6 env(STOCK_FAILS): '0'7 8services:9 App\Payment\Infrastructure\InMemoryPaymentGateway:10 arguments: { $alwaysFails: '%env(bool:PAYMENT_FAILS)%' }11 12 App\Warehouse\Infrastructure\InMemoryStockService:13 arguments: { $alwaysFails: '%env(bool:STOCK_FAILS)%' }14 15 App\Warehouse\Domain\StockService: '@App\Warehouse\Infrastructure\InMemoryStockService'16 App\Shipping\Domain\ShippingService: '@App\Shipping\Infrastructure\InMemoryShippingService'
Zbylé handlery kroků mají stejnou stavbu jako ChargeCustomerHandler: vykonají operaci
a vydají událost. Ty, které jen mění stav agregátu, jsou ještě kratší:
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Application\Handler;6 7use App\Ordering\Application\Command\MarkOrderPaid;8use App\Ordering\Domain\Repository\OrderRepository;9use App\Ordering\Domain\ValueObject\OrderId;10use Doctrine\ORM\EntityManagerInterface;11use Symfony\Component\DependencyInjection\Attribute\Target;12use Symfony\Component\Messenger\Attribute\AsMessageHandler;13use Symfony\Component\Messenger\MessageBusInterface;14 15#[AsMessageHandler(bus: 'command.bus')]16final readonly class MarkOrderPaidHandler17{18 public function __construct(19 private OrderRepository $orders,20 private EntityManagerInterface $em,21 #[Target('event.bus')]22 private MessageBusInterface $eventBus,23 ) {}24 25 public function __invoke(MarkOrderPaid $command): void26 {27 $order = $this->orders->get(OrderId::fromString($command->orderId));28 $order->markPaid();29 $this->em->flush();30 31 // markPaid() nahrává OrderPaid. Bez tohoto kroku by událost32 // zůstala v agregátu a projekce by o změně stavu nevěděla.33 // Dispatch uvnitř transakce je zde v pořádku: event.bus je34 // synchronní a nic neopouští proces.35 // Kdyby událost mířila do brokera, patřila by do outboxu.36 foreach ($order->releaseEvents() as $event) {37 $this->eventBus->dispatch($event);38 }39 }40}41 42// ShipOrderHandler je totožný, jen volá43// $order->ship(ShipmentId::fromString($command->shipmentId));
Příkazy samotné jsou prosté DTO. Primitivy nesou proto, že putují přes asynchronní
transport: co se serializuje do fronty, musí jít bez ztráty sestavit zpátky. Hodnotový
objekt to zvládne, pokud má veřejný konstruktor a veřejné vlastnosti. CancelOrder
z kapitoly o autorizaci nese OrderId právě z tohoto důvodu. Řetězec je ale odolnější
vůči změnám: přejmenované pole ve VO shodí každou zprávu, která ve frontě čekala z minulé
verze aplikace.
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Application\Command;6 7final readonly class MarkOrderPaid8{9 public function __construct(public string $orderId) {}10}11 12final readonly class ShipOrder13{14 public function __construct(15 public string $orderId,16 public string $shipmentId,17 ) {}18}
Příkazy pro cizí kontexty mají tentýž tvar a doplňují je kompenzace ze sekce 14.02:
1<?php2 3declare(strict_types=1);4 5// --- src/Warehouse/Application/Command/ReserveStock.php + ReleaseStock.php ---6namespace App\Warehouse\Application\Command;7 8final readonly class ReserveStock9{10 public function __construct(public string $orderId) {}11}12 13final readonly class ReleaseStock14{15 public function __construct(public string $orderId) {}16}17 18// --- src/Shipping/Application/Command/CreateShipment.php + CancelShipment.php ---19namespace App\Shipping\Application\Command;20 21final readonly class CreateShipment22{23 public function __construct(public string $orderId) {}24}25 26final readonly class CancelShipment27{28 // Zásilka se ruší podle vlastní identity, ne podle objednávky. Sága29 // si shipmentId ukládá z ShipmentCreated – jinde ho v tu chvíli30 // nemá kdo znát.31 public function __construct(32 public string $orderId,33 public string $shipmentId,34 ) {}35}
Zbylých pět handlerů sedí ve svých kontextech a od ChargeCustomerHandler se liší jen
volanou službou a jménem události. Vypsané jsou dva, na kterých je vidět obojí: krok,
který hlásí výsledek událostí, i kompenzace, která nehlásí nic. Vynechat nejde žádný
– chybějící handler se projeví až jako
No handler for message v dead-letter frontě, zatímco proces poběží dál. To je druh
chyby, kterou nikdo nehledá, dokud se stavy nerozejdou.
1<?php2 3declare(strict_types=1);4 5namespace App\Warehouse\Application\Handler;6 7use App\Warehouse\Application\Command\ReleaseStock;8use App\Warehouse\Application\Command\ReserveStock;9use App\Warehouse\Domain\Event\StockReservationFailed;10use App\Warehouse\Domain\Event\StockReserved;11use App\Warehouse\Domain\StockService;12use Symfony\Component\DependencyInjection\Attribute\Target;13use Symfony\Component\Messenger\Attribute\AsMessageHandler;14use Symfony\Component\Messenger\MessageBusInterface;15use Symfony\Component\Uid\Uuid;16 17#[AsMessageHandler(bus: 'command.bus')]18final readonly class ReserveStockHandler19{20 public function __construct(21 private StockService $stock,22 #[Target('event.bus')]23 private MessageBusInterface $eventBus,24 ) {}25 26 public function __invoke(ReserveStock $command): void27 {28 try {29 $this->stock->reserve($command->orderId);30 } catch (\RuntimeException $e) {31 $this->eventBus->dispatch(new StockReservationFailed(32 eventId: Uuid::v7(),33 orderId: $command->orderId,34 failureReason: $e->getMessage(),35 ));36 37 return;38 }39 40 $this->eventBus->dispatch(new StockReserved(41 eventId: Uuid::v7(),42 orderId: $command->orderId,43 ));44 }45}46 47#[AsMessageHandler(bus: 'command.bus')]48final readonly class ReleaseStockHandler49{50 public function __construct(private StockService $stock) {}51 52 public function __invoke(ReleaseStock $command): void53 {54 // Kompenzace nevydává událost: na uvolnění rezervace nikdo nečeká.55 $this->stock->release($command->orderId);56 }57}
CreateShipmentHandler v kontextu Shipping volá ShippingService::create() a úspěch
hlásí událostí ShipmentCreated s identifikátorem zásilky ze služby. CancelShipmentHandler je proti
němu kratší: zavolá cancel($command->shipmentId) a nevydá nic. Kdyby vydal
ShipmentCreated, Process Manager by na ni ve stavu Compensating odpověděl dalším
CancelShipment a vznikla by nekonečná smyčka příkazu a události.
RefundCustomerHandler je protějšek ChargeCustomerHandler: zavolá
PaymentGateway::refund() a podle výsledku vydá RefundSucceeded, nebo RefundFailed.
CancelOrderHandler má vlastní výpis v kapitole o autorizaci
a je potřeba právě ten. Musí totiž rozpoznat systémovou identitu a uvolnit
zámek – jinak kompenzace narazí na OrderLockedBySagaException, skončí v dead-letter frontě
a objednávka zůstane zaplacená, nezrušená a zamčená navždy. Verze „jako
MarkOrderPaidHandler, jen volá cancel()“ pro ságu nestačí.
Kolik logiky smí Process Manager mít
CQRS Journey odpovídá striktně: Process Manager zprávy jen routuje a překládá mezi
typy, doménová logika patří do agregátů. OrderProcessManager z ukázky tuto hranici
překračuje. Rozhoduje, kdy se kompenzuje, jakým příkazem a jak dlouho se čeká na
odpověď.
Kniha se drží volnějšího Richardsonova výkladu a tu odchylku pojmenovává. Orchestrátor smí znát pořadí kroků, podmínky přechodů a časové limity, protože to je logika procesu. Nepatří do žádného z agregátů, které proces koordinuje. Pravidla jednoho agregátu do něj naopak nepatří: výpočet ceny, kontrola dostupnosti zboží, ověření kreditního limitu. Ta zůstávají v doméně a Process Manager si pro ně posílá příkaz. Praktický test: obsahuje-li třída ságy podmínku nad doménovými daty, na kterou by uměl odpovědět agregát, sedí ta logika ve špatné vrstvě.
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 smazal informaci o tom, kde se proces nachází.
Sága by navždy „visela“ a nikdo by ji nedokončil ani nezkompenzoval. Stav ságy se proto
ukládá do databáze jako Doctrine entita.
Entita leží v Application vrstvě: stav procesu je aplikační starost, ne součást doménového modelu. Doctrine atributy nese přímo, stejně jako doménové entity v celém průvodci (Mapping volba). Kdo trvá na přísném vrstvení (Hexagonal Architecture), přesune mapování do Infrastructure.
Perzistence stavu je předpokladem obnovy po selhání. Když worker spadne uprostřed
zpracování zprávy PaymentSucceeded a nestihne ji potvrdit, Messenger po restartu
tutéž zprávu doručí znovu a Process Manager si stav ságy načte z databáze.
Ví tedy, že proces čekal na platbu (AwaitingPayment), a neztratil kontext.
Samo o sobě to ale nestačí. Ukázaná metoda onPaymentSucceeded() nemá guard proti
opakovanému doručení, takže po redelivery provede přechod i příkaz ReserveStock podruhé. Obnovu bez
duplicit dodá až idempotentní přechod z podsekce
Multi-worker Process Manager, který zpracované události
eviduje a druhé doručení zahodí. Metoda findStale() v repozitáři pak 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
Optimistický zámek řeší konflikt na jedné instanci ságy. V produkci nastávají
složitější situace: stejná zpráva (např. PaymentSucceeded z téže objednávky)
dorazí do více instancí workeru současně (supervisor s numprocs > 1),
nebo různé události téže ságy dorazí ve špatném pořadí (Kafka partition
balancing, RabbitMQ multiple consumers). Důsledky:
- Race na vznik ságy. První
OrderPlacedpro stejnéorderIddorazí do dvou workerů současně. Oba vidí, že sága ještě neexistuje, a oba zavolajíOrderSaga::start. UNIQUE constraint na(saga_type, correlation_id)jeden zápis odmítne, druhý zůstane. Bez constraintu vzniknou dvě paralelní ságy téže objednávky a soupeří o stav. - Události mimo pořadí.
PaymentSucceededdorazí dřív nežOrderPlaced, sága ještě není ve stavuAwaitingPayment. Process Manager ji buď zahodí (chyba v doméně), nebo odloží do fronty pending ke zpracování později (za cenu složitějšího stavového automatu). - Kompenzační závody. Sága přejde do
Compensating, vyšleRefundCustomer, a zároveň z jiného workeru dorazí opožděnáStockReserved. Ta může přepnout stav ságy zCompensatingzpět naAwaitingShipment, aleRefundCustomeruž běží – zákazník dostane refund, a přesto proces pokračuje k expedici.
Obrana proti všem třem:
Guard patří do každého kroku, ne jen do platby. OrderSaga proto dostane obdobné
applyStockReserved() a applyShipmentCreated(); liší se jen očekávaným stavem a cílem
přechodu. Bez nich vyrobí opakovaně doručená StockReserved druhou i třetí zásilku.
Sága si zapamatuje jen tu, jejíž ShipmentCreated dorazí první, a přejde do Completed.
Zbylé dvě události skončí na guardu terminálního stavu a jejich zásilky nikdo nezruší.
Objednávka přitom skončí shipped a dead-letter fronta zůstane prázdná – nikde se to nepozná.
Druhá polovina obrany patří do agregátu. cancel(), markPaid() i ship()
v kanonické verzi z Návrhu agregátu při opakovaném
doručení tiše končí. Bez té větve by opakované MarkOrderPaid skončilo po třech
opakováních v DLQ s InvalidOrderStateTransitionException (přechod z paid do paid). Data by se
nerozbila, ale nikdo by se o tom nedozvěděl. Pro připomenutí výřez:
1public function markPaid(): void2{3 // Opakované doručení příkazu není chyba volajícího, jen už není4 // co dělat. Transport garantuje at-least-once, ne exactly-once.5 if ($this->status === OrderStatus::Paid) {6 return;7 }8 9 if ($this->status !== OrderStatus::Confirmed) {10 throw InvalidOrderStateTransitionException::cannotTransition(11 $this->status->value,12 OrderStatus::Paid->value,13 );14 }15 16 $this->status = OrderStatus::Paid;17 $this->record(new OrderPaid($this->id, new \DateTimeImmutable()));18}19 20// ship() má tutéž větev pro OrderStatus::Shipped.
Tři stavební prvky, které zde fungují společně:
- UNIQUE constraint na
(saga_type, correlation_id)zabrání duplicitnímu vzniku ságy. Druhý INSERT vyhodíUniqueConstraintViolationException, handler ji zachytí, resetuje EntityManager a skončí bez odeslání druhého příkazu. processedEventIdsv entitě drží identifikátory již zpracovaných událostí. Když táž událost přijde podruhé, volání skončí na guardu. Jde o „inbox per saga“, obdobu Idempotent Inboxu z kapitoly Outbox.- Guard stavového automatu odmítne událost, která dorazila mimo pořadí. Buď ji zahodí, nebo ji odloží do sloupce pending events ke zpracování později.
Řešení má dvě omezení. Kontrola in_array nad JSON sloupcem
není atomická: dva workery mohou projít guardem současně, protože každý pracuje nad
svou kopií načtenou před zápisem. Duplicitní zápis zachytí až optimistický zámek
a jeden z workerů dostane OptimisticLockException. Guard tedy odfiltruje běžné
redelivery, souběh řeší až verze řádku. Druhé omezení je růst: processedEventIds
se nikdy nezmenšuje. U ságy s desítkami kroků to nevadí. U dlouhoběžících procesů
s tisíci událostí ale sloupec bobtná. Řešením je vyprázdnit ho po dokončení ságy,
nebo držet jen posledních N identifikátorů.
Guard navíc otevírá vlastní okno. save() zapíše stav i zpracované eventId dřív,
než odejdou příkazy. Spadne-li worker mezi zápisem a dispatchem, redelivery skončí
na guardu a MarkOrderPaid ani ReserveStock už neodejdou. Sága pak stojí
v AwaitingStockReservation a zachytí ji až detekce zaseklých ság.
Okno zavře jen zápis stavu ságy a odchozích příkazů v jedné transakci, například
přes Outbox.
Distributed deadlock mezi ságami
Klasický deadlock dvou agregátů přes pesimistický zámek Doctrine: sága A drží
zámek na Order#1 a žádá o Inventory#42; sága B drží zámek na Inventory#42
a žádá o Order#1. Detektor deadlocků v PostgreSQL jednu z transakcí po zhruba
1 s ukončí (výchozí deadlock_timeout), do té doby ale oba workery stojí.
S eventual consistency (Vernonovo „eventual consistency mimo hranici agregátu“, viz Návrh agregátu) databázový deadlock nastat nemůže: každý krok ságy je samostatná transakce nad jedním agregátem. Možný je ale jiný typ: logický cyklický deadlock v sáze samotné.
Příklad: sága OrderProcess čeká na PaymentSucceeded. Sága RefundProcess (pro
storno) čeká na OrderCancelled. Když kompenzace vyvolá storno objednávky
a zároveň zrušení refundu, čekají ságy jedna na druhou a žádná nedoběhne.
Recovery z nekonzistentního stavu ságy
Sága může skončit v nekonzistentním stavu i bez chyby ve svém kódu: nasazení uprostřed transakce, OOM kill v polovině kompenzačního kroku, migrace schématu, která změnila tvar uloženého JSONu. Operátor potřebuje tři nástroje.
Prvním je read-only inspekce. Příkaz app:saga:show <id> vypíše aktuální stav,
čekající události, zpracovaná ID událostí a počet pokusů, plus odkaz na ságu
v Grafaně. Druhým je manuální přechod: app:saga:force-transition <id> <to>
s povinným --reason="..." aktualizuje status, zapíše audit log a zneplatní čekající
události. Patří výhradně operátorům a každé jeho použití signalizuje chybu v sáze nebo
neošetřený doménový scénář. Třetí nástroj je replay od checkpointu: u idempotentní
ságy stačí smazat stav a přehrát všechny její události z outboxu nebo event store.
Podmínkou je znát správnou počáteční událost (typicky ID události OrderPlaced).
Izolace ság: ACD bez I
Databázová transakce dává ACID. Sága jen ACD: atomicitu přes kompenzace, konzistenci a trvanlivost. Izolace chybí: každý commitnutý krok je okamžitě viditelný všem souběžným procesům, dlouho předtím, než celá sága skončí. Jiný proces nad stejnou objednávkou nebo skladem může rozpracovaný stav přečíst i přepsat. Vznikají anomálie známé z databází: lost update (storno ságy přepíše změnu, kterou objednávková sága právě provádí) a dirty read (proces si přečte platbu, kterou kompenzace vzápětí vrátí).
Richardson pro tyto anomálie popisuje šest protiopatření (countermeasures);
kniha rozebírá čtyři. První dvě pracují s daty. Semantic lock je aplikační zámek: záznam, na kterém
sága pracuje, nese stav s příznakem *_PENDING a ostatní procesy ho musí
respektovat. Commutative updates jsou operace navržené tak, aby na pořadí
nezáleželo – připsání a odepsání částky komutuje, nastavení absolutního
zůstatku ne.
Další dvě pracují s průběhem ságy. Pessimistic view přeuspořádává kroky: změna, jejíž dirty read napáchá největší škodu (třeba připsání kreditu), se přesune za pivot transakci (viz Když selže kompenzace). Při reread value si krok před zápisem hodnotu znovu načte a ověří, že se od prvního čtení nezměnila; jinak ságu zastaví nebo opakuje. Zbývající version file a by value zde rozebírané nejsou.
Semantic lock je z nich nejčastější:
Chybějící díly jsou dva prosté typy:
1<?php2 3declare(strict_types=1);4 5// --- src/Ordering/Application/Command/ReleaseOrderLock.php ---6namespace App\Ordering\Application\Command;7 8final readonly class ReleaseOrderLock9{10 public function __construct(public string $orderId) {}11}12 13// --- src/Ordering/Domain/Exception/OrderLockedBySagaException.php ---14namespace App\Ordering\Domain\Exception;15 16use App\Ordering\Domain\ValueObject\OrderId;17 18final class OrderLockedBySagaException extends \DomainException19{20 public function __construct(public readonly OrderId $orderId)21 {22 parent::__construct(sprintf(23 'Order "%s" is locked by a running process.',24 $orderId->value,25 ));26 }27}
Handler je krátký, ale nosný: bez něj zůstane objednávka zamčená i po úspěšném doběhnutí procesu a zákazník ji nezruší nikdy.
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Application\Handler;6 7use App\Ordering\Application\Command\ReleaseOrderLock;8use App\Ordering\Domain\Repository\OrderRepository;9use App\Ordering\Domain\ValueObject\OrderId;10use Doctrine\ORM\EntityManagerInterface;11use Symfony\Component\Messenger\Attribute\AsMessageHandler;12 13#[AsMessageHandler(bus: 'command.bus')]14final readonly class ReleaseOrderLockHandler15{16 public function __construct(17 private OrderRepository $orders,18 private EntityManagerInterface $em,19 ) {}20 21 public function __invoke(ReleaseOrderLock $command): void22 {23 $order = $this->orders->get(OrderId::fromString($command->orderId));24 $order->releaseSagaLock();25 26 // Žádná událost. Uvolnění zámku není doménová změna, na kterou27 // by někdo čekal – jen konec výhradního přístupu procesu.28 $this->em->flush();29 }30}
Zámek má cenu jen tehdy, když ho někdo uvolní i při selhání, jinak zůstane objednávka
zablokovaná navždy. Uvolnění proto patří do každé terminální větve ságy, ne jen
do té úspěšné. V ukázkách knihy to dělá metoda finish() v Process Manageru a u větví
končících stornem sám CancelOrderHandler, protože příkaz přichází pod systémovou
identitou.
Samotný zámek ale nestačí. OrderProcessManager proto navíc odebírá OrderCancelled
a při stornu spustí kompenzaci hotových kroků. Každý ze tří mechanismů řeší jinou část
téhož problému:
- Zámek brání stornu v okně, kdy proces běží. Bez něj by uživatel zrušil objednávku
uprostřed ságy, ta by dál strhla platbu a vytvořila zásilku – a příkazy
MarkOrderPaidaShipOrderby pak jeden po druhém skončily v DLQ, aniž by si toho kdokoli všiml. - Reakce na
OrderCancelledpokrývá storna mimo to okno a případ, kdy objednávku zruší jiný proces. - Guard na
CompensatingvonStockReserved()aonShipmentCreated()ošetřuje opožděný úspěch: rezervace nebo zásilka dorazí až po zahájení kompenzace, takže se rovnou zase uvolní. Kontrola na terminální stav sama nestačí, protožeCompensatingterminální není.
Že jsou potřeba všechny tři, je vidět až za běhu. Sága bez nich doběhne do Completed
nad zrušenou objednávkou a rozdíl se pozná jedině čtením dead-letter fronty.
Volba mezi zámkem a plnou reakcí je doménová: smí zákazník zrušit objednávku, u které už běží platba? Odpověď „ne, ať to zkusí za chvíli“ je přípustná a levnější.
Tato volba má ale důsledek. Když se kapitoly této knihy složí dohromady,
vzniká každá objednávka rovnou uzamčená (placeWithItems() volá lockForSaga()) a zámek
uvolní až sága ve chvíli, kdy je objednávka shipped nebo cancelled. Zákazník se tak
k vlastnímu stornu nedostane nikdy: dokud proces běží, tlačítko se nenabídne, a až
doběhne, je pozdě. Celý tok z kapitoly o autorizaci pak v integrované aplikaci obsluhuje
jen systémový aktér.
Pro ukázkový e-shop je to obhajitelné, pro skutečný zpravidla ne. Kdo chce zákazníkovi
storno umožnit, buď zámek nasadí až od kroku, který se špatně vrací (typicky vytvoření
zásilky, ne strhnutí platby), nebo ho vynechá a spolehne se na reakci ságy
na OrderCancelled.
Kolizní požadavky lze místo výjimky také frontovat a provést po uvolnění zámku; pro většinu domén ale stačí odmítnutí výjimkou a opakování na straně klienta.
Citace: Richardson, C., Microservices Patterns (2018), kap. 4.
14.07 Implementace v Symfony Messenger#
Process Manager a jeho stav propojuje Symfony Messenger, asynchronní message bus, který doručuje události a příkazy mezi kontexty. Základní konfiguraci busů popisuje kapitola CQRS – Symfony Messenger. Pro ságy jsou specifické oddělené transporty pro události a příkazy a retry strategie, bez kterých dlouhotrvající procesy při běžných výpadcích ztrácejí zprávy.
Celý tok: kontext Ordering publikuje přes outbox integrační událost
OrderPlacedIntegrationEvent. Messenger ji podle 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#
Událost PaymentSucceeded nemusí dorazit nikdy: síťový výpadek, nedostupná platební
brána, ztracená zpráva ve frontě. V distribuovaném systému se s chybějící odpovědí počítá
vždy. Bez explicitního timeoutu zůstane sága navždy ve stavu AwaitingPayment
a objednávka se nikdy nedokončí ani nezruší. Řešením je timeout check, odložený příkaz, který po uplynutí
stanovené doby zkontroluje, zda se sága posunula dál. Pokud ne, ságu ukončí,
nebo spustí kompenzaci – podle toho, zda už proběhl krok, který je třeba vrátit.
Timeout se plánuje v Process Manageru při každém přechodu do stavu, ve kterém sága
na něco čeká. Ne jen jednou na začátku procesu: kdyby se hlídal pouze první krok,
sága uvízlá ve stavu AwaitingStockReservation by nikdy nikoho neupozornila.
Messenger k tomu nabízí DelayStamp, který zprávu podrží v transportu po zadanou
dobu a teprve poté ji doručí workeru:
Úplný výpis OrderProcessManager v 14.05 tuto metodu nemá;
jde o doplněk. Bez něj sága běží bez hlídačů; s ním rozesílá i CheckSagaTimeout,
který proto unit test v 14.12 odfiltruje metodou steps().
Volání scheduleTimeout() patří do každé metody, která ságu převede do čekajícího
stavu. Metoda onPaymentSucceeded() tak naplánuje kontrolu pro
AwaitingStockReservation. Kontroly naplánované pro stav, který sága mezitím
opustila, zahodí handler hned na první podmínce. Plánování se proto nikde neruší.
Stav AwaitingShipment v konstantě chybí záměrně. Rezervace skladu je v tomto
procesu pivot transakce (viz Když selže kompenzace), takže za
ní se už nekompenzuje a prošlý čas nemá vyvolat kompenzaci, ale eskalaci
operátorovi. Tu obstará detekce zaseklých ság ze sekce 14.11.
14.09 Kompenzační strategie v praxi#
Na selhání kroku ságy existují dvě základní strategie. Volba závisí na tom, zda je chyba 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 stačí opakování, tedy nový pokus o tentýž krok.
Pojmenování má háček: u Garcii-Moliny a Salema znamená forward recovery
restart od save-pointu bez kompenzací, tedy něco jiného. Kniha termín používá
v dnešním, užším významu. Symfony Messenger nabízí vestavěnou retry strategii
s exponenciálním backoffem, nastavenou v
sekci 14.07. Worker automaticky opakuje selhané
zprávy podle nastavení max_retries, delay a multiplier.
Backward recovery (kompenzace)
Při trvalých chybách (selhání s doménovou příčinou) nastupuje kompenzace:
vrátit systém do konzistentního stavu kompenzačními akcemi v
opačném pořadí dokončených kroků. Kompenzace je
sémantická, nikoli technická: místo DELETE FROM payments se odešle 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.
Když selže kompenzace
Metoda onStockReservationFailed ze sekce 14.05 dispatchuje
RefundCustomer a převede ságu do stavu Compensating. Tam sága zůstává, protože
refund je asynchronní příkaz: do terminálního Failed smí přejít až poté,
co dorazí potvrzení RefundSucceeded. Přechod do Failed hned po dispatchi
by ságu uzavřel dřív, než refund proběhl; při jeho selhání by se po penězích
zákazníka nikdo nesháněl. Stav „kompenzace odeslána, čeká se na potvrzení“
se označuje jako compensation pending.
Richardson (Microservices Patterns, 2018, kap. 4) dělí kroky ságy do tří skupin:
- Compensatable transactions – kroky před pivotem; každý má definovanou
kompenzaci (
ChargeCustomer↔RefundCustomer). - Pivot transaction – bod rozhodnutí. Jakmile commitne, sága už necouvá a poběží dopředu až do konce. V tomto procesu je pivotem rezervace skladu.
- Retriable transactions – kroky po pivotu (
CreateShipment,ShipOrder). Kompenzaci nemají, nesmí selhat z doménových důvodů a opakují se až do úspěchu.
Klasifikace popisuje selhání kroku uvnitř ságy. Storno objednávky zvenčí je jiný
scénář, a proto CancelShipment v tabulce kompenzací ze sekce 14.02
figuruje. Dorazí-li storno dřív, než Shipping ohlásí zásilku, je sága už ve stavu
Compensating a vzniklou zásilku zruší onShipmentCreated(). Po vytvoření zásilky sága
končí v Completed a zrušení odeslané objednávky už je reklamace, ne kompenzace.
Kompenzace samy patří do třetí kategorie. RefundCustomer nemá žádný
doménový důvod selhat – peníze, které systém strhl, musí umět vrátit. Selhání
je vždy technické: nedostupná platební brána, timeout, chyba sítě. Odpovídá
tomu strategie: retry s exponenciálním backoffem (retry_strategy ze
sekce 14.07), po vyčerpání pokusů zpráva končí ve
failure transportu, systém odešle alert a objednávka putuje do fronty ručních
zásahů. Ságu visící v Compensating zachytí i detekce zaseklých ság ze
sekce 14.11.
Podrobnější informace o Dead Letter Queue, retry strategiích a zpracování chyb v Messengeru najdete v kapitole CQRS – zpracování chyb.
14.10 Paralelní kroky#
Dosud běžely kroky sériově, jeden po druhém. Některé ale na sobě nezávisí a mohou běžet současně: po úspěšné platbě lze zároveň rezervovat zboží na skladě a vygenerovat fakturu. Výsledek jedné operace druhou neovlivňuje, takže paralelní zpracování zkrátí celkovou dobu trvání ságy.
Sága odešle oba příkazy najednou a přejde do stavu AwaitingStockAndInvoice.
V kontextu si uchovává dva příznaky (stockReserved a invoiceCreated). Teprve když
jsou splněné oba, pokračuje dalším krokem, vytvořením zásilky. Tomuto vzoru se říká
synchronizační bariéra (synchronization barrier). Stav AwaitingStockAndInvoice
v enumu OrderSagaStatus ze sekce 14.05 zatím chybí; paralelní varianta
ho do enumu doplní.
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. Provoz proto potřebuje vědět, které instance ságy právě běží, které se zasekly a které selhaly. Tuto viditelnost zajišťují dva nástroje: 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ý byznysový korelační klíč, zde
orderId. Podle něj jde v logu vyfiltrovat všechny zprávy konkrétní objednávky
a sledovat celý průběh procesu od začátku do konce.
Technické korelační ID má jiný rozsah. Vzniká
s requestem nebo spouštěcí zprávou a přechází do zpráv z nich odvozených.
Klíč orderId naproti tomu spojuje celou instanci ságy, i přes naplánované
timeouty a ruční zásahy.
Technicky se korelace řeší vlastním stampem (např. CorrelationIdStamp),
který sága připojí na envelope při dispatchi a logovací middleware ho čte
ze stampu, místo aby spoléhal na konkrétní pole zprávy. Envelope tak nese
korelaci i pro zprávy, které žádné orderId nemají.
Detekce zaseklých ság
I při nejlepším návrhu se občas zpráva ztratí, worker spadne nebo externí služba přestane odpovídat. Proto je potřeba cron/scheduled command, který pravidelně kontroluje, zda některá sága nezůstala příliš dlouho v mezistavech:
Podrobnosti o implementaci middlewaru v Symfony Messengeru najdete v kapitole CQRS – sekce middleware.
14.12 Testování ság#
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ě. Těžiště testů ságy leží v unit testech stavového automatu; integrační testy s Doctrine a testování asynchronních toků přes Messenger rozebírá kapitola Testování DDD.
Unit testy stavového automatu
Nejdůležitější úroveň: unit test ověřuje samotný Process Manager bez infrastruktury. Message bus nahrazuje spy, který zaznamenává odeslané příkazy, databázi in-memory repozitář:
Další vzory pro testování doménové logiky, agregátů a event handlerů najdete v kapitole Testování DDD.
Časté otázky
Jaký je rozdíl mezi Ságou a Process Managerem?
Sága je v této knize 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 orchestrační komponenta s vlastním stavem, která takovou ságu řídí: zasílá příkazy a reaguje na přicházející události. Obě 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. Jde ale o jednu ze tří konvencí, které v literatuře koexistují, takže u cizího zdroje je vhodné ověřit, kterou používá. Rozbor v sekci Kterou konvenci kniha používá.
Choreografie, nebo orchestrace – kdy zvolit co?
Choreografie, kde služby reagují na události publikované ostatními, se hodí pro krátké procesy se známou lineární posloupností kroků, kde je spojení mezi službami volné a globální stav není kritický. Orchestrace přes Process Manager je vhodnější tam, kde posloupnost není známá dopředu, kde se proces větví nebo kde jsou potřeba časové limity a centrální přehled o stavu běhu. Běžně citovaná hranice „dvou až tří kroků“ je heuristika, ne pravidlo. Rozhodovací kritéria v sekci Limity choreografie.
Jak implementovat kompenzační transakce v Symfony?
Kompenzace je samostatná operace nebo command handler, který sémanticky vrací efekt dříve dokončeného kroku – například RefundCustomer jako protějšek ChargeCustomer. V sáze nad Messengerem 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, ať už při selhání workera, nebo při přebalení na retry queue. Handler proto musí opakované zpracování bezpečně ignorovat. Standardní řešení jsou dvě: identifikátor události (ne transportní ID zprávy) uložený mezi zpracovaná 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 Idempotentní state transitions.
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. Role se nezaměňují: událost nic nepřikazuje, příkaz nic neoznamuje. Viz sekci Implementace v Symfony Messenger.