Kapitola 14 · Vzory · Ságy a Process Managery

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í.

Autor M. Katuščák
Doba čtení ≈ 48 min
Náročnost náročná
Publikováno · Aktualizováno ·
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:

  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 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.

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

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:

FIG. 14.5-A Stavový automat OrderProcessManager

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.

php src/Payment/Domain/Event/ + Warehouse/ + Shipping/ (obdobně)
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:

php src/Payment/Application/Handler/ChargeCustomerHandler.php
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:

php src/Payment/Domain/PaymentGateway.php
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.

php src/Payment/Infrastructure/InMemoryPaymentGateway.php
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:

php src/Warehouse/Domain/StockService.php + src/Shipping/Domain/ShippingService.php + src/Warehouse/Infrastructure/InMemoryStockService.php + src/Shipping/Infrastructure/InMemoryShippingService.php
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:

yaml config/services.yaml (výřez)
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ší:

php src/Ordering/Application/Handler/MarkOrderPaidHandler.php (+ ShipOrderHandler)
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.

php src/Ordering/Application/Command/MarkOrderPaid.php + ShipOrder.php
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:

php src/Warehouse/Application/Command/ReserveStock.php + ReleaseStock.php + src/Shipping/Application/Command/CreateShipment.php + CancelShipment.php
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.

php src/Warehouse/Application/Handler/ReserveStockHandler.php + ReleaseStockHandler.php
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í OrderPlaced pro stejné orderId dorazí 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í. PaymentSucceeded dorazí dřív než OrderPlaced, sága ještě není ve stavu AwaitingPayment. 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šle RefundCustomer, a zároveň z jiného workeru dorazí opožděná StockReserved. Ta může přepnout stav ságy z Compensating zpět na AwaitingShipment, ale RefundCustomer už 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:

php src/Ordering/Domain/Model/Order.php (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.
  • processedEventIds v 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:

php src/Ordering/Application/Command/ReleaseOrderLock.php + src/Ordering/Domain/Exception/OrderLockedBySagaException.php
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.

php src/Ordering/Application/Handler/ReleaseOrderLockHandler.php
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 MarkOrderPaid a ShipOrder by pak jeden po druhém skončily v DLQ, aniž by si toho kdokoli všiml.
  • Reakce na OrderCancelled pokrývá storna mimo to okno a případ, kdy objednávku zruší jiný proces.
  • Guard na Compensating v onStockReserved() a onShipmentCreated() 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že Compensating terminá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.

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

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.