7 lipca 2026 · 8 min czytania · Kamil Sułek

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 generatorze SEQUENCE persist 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 … COMMIT wokół 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 SEQUENCE na Postgresie — Doctrine pobiera nextval już przy persist(), bez INSERT-u, więc getId() 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ą.