Dwa flushe, dwie transakcje: jak niechcący zabić Outbox w Doctrine
persist() planuje, flush() wykonuje SQL i — bez jawnej transakcji — commituje. Dwa flushe to dwie transakcje, czyli okno, w którym event ginie. Dlaczego to rozbraja cały sens Outbox pattern, jak flush działa pod spodem i ile realnie kosztuje commit.
Oto okaz do code review. Kod się kompiluje, w happy pathie działa, nic nie rzuca wyjątkiem — a mimo to jest w nim defekt poprawności, który wywraca cały wzorzec, którego używa.
public function createOrder(OrderDTO $dto): Order
{
$order = new Order($dto);
$this->em->persist($order);
$this->em->flush(); // ①
$event = new OutboxMessage(
type: 'order.created',
payload: json_encode($dto),
aggregateId: $order->getId(),
);
$this->em->persist($event);
$this->em->flush(); // ②
return $order;
}
Intencja jest dobra: zapisz zamówienie i dołóż wpis do outboxa, żeby osobny proces
opublikował event. Problem jest w tych dwóch flush(). Żeby go zobaczyć, trzeba wiedzieć,
co flush() naprawdę robi pod spodem.
Co robi persist(), a co flush()
W Doctrine to są dwie różne rzeczy:
persist($entity)niczego nie zapisuje. Mówi tylko UnitOfWork: „weź tę encję pod zarząd, przy najbliższym flushu zaplanuj jej INSERT". Żadnego INSERT-u. (Jedyny wyjątek: przy generatorzeSEQUENCEpersist pobiera z bazy następny numer sekwencji — o tym niżej — ale samej encji i tak nie zapisuje.)flush()liczy change-set wszystkich zarządzanych encji i wykonuje SQL. I tu kluczowy fakt: jeśli nie ma jawnie otwartej transakcji,flush()sam ją otwiera, wykonuje zapisy i commituje. Jeden flush =BEGIN … COMMITwokół całej paczki zmian.
Doctrine robi to celowo — pojedynczy flush jest atomowy: albo wszystkie zaplanowane zmiany wejdą razem, albo (przy wyjątku) całość się wycofa. To dobra własność. Problem pojawia się, gdy flushujesz dwa razy.
Dwa flushe = dwie transakcje
Skoro każdy flush bez otwartej transakcji sam się commituje, powyższy kod to nie jedna transakcja — to dwie:
flush ① → BEGIN, INSERT orders, COMMIT
flush ② → BEGIN, INSERT outbox, COMMIT
Między nimi jest okno. Jeśli cokolwiek padnie po ① a przed ② — wyjątek, zerwane połączenie,
kill procesu, timeout, deploy w złym momencie — masz zamówienie w bazie i zero eventu
w outboxie. Event zaginął bezpowrotnie: proces publikujący nigdy się o zamówieniu nie
dowie, bo nie ma czego czytać.
To jest dokładnie dual-write problem — dwa niezależne commity do jednego logicznego faktu, z oknem awarii pomiędzy. Ironia jest bolesna: jedynym zadaniem Outboxa jest usunięcie tego okna (event zapisany atomowo razem ze zmianą stanu). Ten kod wziął wzorzec, którego cała wartość to atomowość, i zaimplementował go tak, że tę atomowość zniszczył. Odtworzył problem wewnątrz mechanizmu, który miał go rozwiązać.
Dlaczego jest podstępny
Bo to nie jest błąd, który „wywala" test. Każdy flush z osobna jest poprawny składniowo, kod się kompiluje, w happy pathie zamówienie i event lądują w bazie. Defekt ujawnia się tylko przy awarii — a te w happy-path code review nie występują. Dlatego przechodzi recenzję u kogoś, kto patrzy, „czy działa", zamiast „co się stanie, gdy padnie między linijkami".
Skąd w ogóle dwa flushe
Jest realny powód, nie głupota: jeśli Order ma ID generowane przez bazę
(IDENTITY / auto-increment), to $order->getId() jest null aż do pierwszego flusha.
Autor potrzebuje ID do aggregateId eventu — więc flushuje, żeby baza nadała ID, i dopiero
potem buduje event. Intencja zrozumiała, wykonanie rozbraja atomowość.
Jak zrobić to dobrze
Dwa czyste wyjścia:
1. Tożsamość nadana w agregacie (idiomatyczne DDD). Niech ID to UUID generowane
w konstruktorze, nie przez bazę. Wtedy getId() istnieje przed flushem:
$order = new Order($dto); // ID już jest (UUID w konstruktorze)
$event = new OutboxMessage(type: 'order.created', payload: ..., aggregateId: $order->getId());
$this->em->persist($order);
$this->em->persist($event);
$this->em->flush(); // jeden flush → jedna transakcja → atomowo
Agregat sam generuje swoją tożsamość — bonus: encja jest kompletna, zanim dotknie bazy.
2. Jawna transakcja. Jeśli musisz zostać przy ID z bazy, owiń całość:
return $this->em->wrapInTransaction(function () use ($dto) {
$order = new Order($dto);
$this->em->persist($order);
$this->em->flush(); // wewnątrz otwartej transakcji NIE commituje
$event = new OutboxMessage(..., aggregateId: $order->getId());
$this->em->persist($event);
$this->em->flush(); // nadal ta sama transakcja
return $order; // wrapInTransaction przekazuje wartość callable'a
}); // COMMIT dopiero tutaj — oba INSERTy albo żaden
Kluczowy niuans: wewnątrz jawnie otwartej transakcji flush() nie commituje — commit
jest odroczony do końca wrappera. Wtedy nawet podwójny flush jest w porządku i atomowy.
Gdyby autor powiedział „dwa flushe są celowe, bo potrzebuję ID, ale owinąłem w
wrapInTransaction" — miałby rację. Bez tego owinięcia „każdy flush jest ok" myli
poprawność pojedynczego flusha z atomowością całości.
Ile to kosztuje — bo to też koszt wydajności
Podwójny flush to nie tylko utracona atomowość, to również wolniej. Najdroższą częścią
zapisu nie jest sam INSERT, tylko COMMIT — to on wymusza trwałość: fsync WAL-a na dysk,
a przy replikacji synchronicznej czekanie na potwierdzenie repliki. Dwa flushe to dwa
commity — podwajasz najkosztowniejszy element operacji, do tego dwa razy BEGIN/COMMIT
round-trip do bazy i więcej pracy z blokadami.
Batchowanie wszystkich zmian w jeden flush to jedna transakcja — jeden commit. To jest ten rzadki przypadek, gdzie poprawność i wydajność ciągną w tę samą stronę: jeden flush jest i bardziej atomowy, i szybszy. Wielokrotne flushowanie „bo tak wygodniej" fragmentuje pracę na wiele transakcji — płacisz commitami i tracisz gwarancję.
A jeśli handler jest już w transakcji (np. Messenger)?
Częsty przypadek w Symfony: DoctrineTransactionMiddleware (messenger doctrine_transaction)
owija cały handler w jedną transakcję. Wtedy oba flushe wpadają w nią, commit jest jeden i
bug utraty eventu nie gryzie — wygląda, jakby problem znikał sam. Nie znika: przesuwa się
z „utracony event" na „źle dobrana tożsamość".
Bo skoro handler ma być jedną transakcją, to idiomatycznie ma też jeden flush. A wtedy
nie możesz oprzeć aggregateId na autoincrementcie z bazy: ID z IDENTITY powstaje
dopiero przy INSERT, czyli dopiero w flushu — więc nie masz go w chwili, gdy budujesz
OutboxMessage. A event niesie skalarny snapshot (aggregateId + payload), nie żywą relację
do agregatu, więc nie „doczyta" sobie ID przy zapisie. Krótko: z autoincrementu nie zbudujesz
eventu w tej samej, jednej jednostce pracy — bez brzydkich hacków w listenerach
(postPersist + recomputeSingleEntityChangeSet), a to już nie jest kod, który chcesz
utrzymywać. Middleware daje ci atomowość za darmo — ale
dokładnie dlatego wymusza, żeby tożsamość istniała przed bazą.
Zostają dwie drogi i obie znaczą to samo:
- UUID (najlepiej v7 — czasowo-uporządkowany, bez rozjazdu locality indeksu jak v4) generowany w konstruktorze agregatu,
- albo
SEQUENCEna Postgresie — Doctrine pobieranextvaljuż przypersist(), bez INSERT-u, więcgetId()działa przed flushem.
Autoincrement wyklucza się tu sam. A gdybyś mimo to został przy nim i pośrednim flushu — poza
złamaniem idiomu płacisz kosztem wydajności innego rodzaju niż podwójny commit powyżej:
INSERT orders wykonuje się wcześnie i trzyma blokady na wierszu przez całą resztę handlera,
a Unit of Work rozpada się na dwie fazy zamiast jednej paczki. Poprawność masz z middleware,
ale tej ceny on nie zdejmuje.
Dlaczego to sedno Outbox pattern
Cała wartość Outboxa mieści się w jednym zdaniu: event i zmiana stanu są w tej samej transakcji, więc commitują się albo razem, albo wcale. To ta atomowość sprawia, że publikacja zdarzeń jest niezawodna — nie ma zgubionych eventów (commit bez eventu) ani eventów-widm (event bez commitu).
W momencie, w którym rozbijasz to na dwa flushe / dwie transakcje, wracasz do dual-write —
do dokładnie tej awarii, dla której wzorzec istnieje. Dlatego znajomość samej nazwy „Outbox"
nie wystarcza: trzeba rozumieć, że jego gwarancja żyje w słowie „jedna transakcja".
flush() to nie jest „zapisz" — to, bez jawnie otwartej transakcji, jej granica. Kto tego
nie widzi, ten napisze Outbox, który niczego nie gwarantuje.
Reszta to już tylko konsekwencje tej jednej transakcji: osobny proces czyta outbox i publikuje at-least-once (po restarcie może wysłać duplikat), więc konsument musi być idempotentny — ale to osobny temat. Fundamentem jest atomowy zapis. Bez niego cały wzorzec jest dekoracją.
