<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Bartosz Osipiuk</title><description>Senior Software Engineer. Piszę o Javie, backendzie i decyzjach projektowych podjętych w działającym produkcie.</description><link>https://bartoszosipiuk.pl/</link><language>pl</language><atom:link href="https://bartoszosipiuk.pl/rss.xml" rel="self" type="application/rss+xml"/><item><title>Hibernate zgubił moją zmianę</title><link>https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/</link><guid isPermaLink="true">https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/</guid><description>ACID? Tak, ale na poziomie bazy danych, nie narzędzi. Jak Hibernate przy zamykaniu aplikacji Spring Boot robi częściowy commit – i jak temu zapobiec.</description><pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate><content:encoded><![CDATA[<h2 id="eventy-które-nie-chcą-przejść">Eventy, które nie chcą przejść</h2>
<p>Pierwsze zadanie w nowej pracy: „sprawdź, czemu zdarzenia w kolejce nie mogą zostać przetworzone”. Super zadanie na początek, by zaznajomić się z narzędziami w firmie – i kompletnie niezwiązane z Hibernate’em, w którego kodzie ostatecznie wylądowałem. Kod w skrócie: w jednej transakcji oznaczamy rekord nadrzędny i jego rekordy jako usunięte (soft delete) + zapisujemy <a href="https://microservices.io/patterns/data/transactional-outbox.html">transactional <span>outbox<span> (otwiera się w nowej karcie)</span></span></a> z eventem o każdym usuniętym rekordzie.</p>
<p>No dobra, sprawdzam logi, monitoring. Na DLT lądował późniejszy event, który wymagał, żeby wszystkie te dane były już usunięte. Wygląda na to, że w bazie jest jakaś niespójność. Pierwsze podejrzenie – błąd w kodzie albo race condition przez izolację Read Committed. Niestety nie: kod wyglądał w 100% dobrze i nie zmienił się, odkąd pojawił się ten event.</p>
<h2 id="ostatnie-logi-przed-śmiercią-poda">Ostatnie logi przed śmiercią poda</h2>
<p>Zauważyłem, że logi z tej operacji były jednymi z ostatnich na podzie, bo autoscaling akurat go zatrzymywał. Ale nie to było najciekawsze. Inny serwis odczytał event o usunięciu rekordu, a u nas ten rekord wcale nie był usunięty…
Co ciekawe, <strong>ostatni event z tej transakcji dotyczył akurat rekordu, którego zmiana zniknęła</strong>.</p>
<p>Skoro kod wyglądał dobrze, zapytałem bazę. Usunięcie to u nas flaga <code>deleted</code>, więc wiersze dalej tam leżały. Dowiedziałem się, że każdy wiersz w Postgresie ma ukrytą kolumnę <code>xmin</code>, czyli id transakcji, która zapisała jego aktualną wersję. Z <code>age(xmin)</code> można się dowiedzieć, ile transakcji temu to było.</p>
<div><link href="https://bartoszosipiuk.pl/_astro/ec.zzg5e.css"><figure><figcaption></figcaption><pre><code>SELECT id, deleted, age(xmin) AS transactions_ago
FROM record
WHERE parent_id = 42;</code></pre></figure></div>
<p>Rekord nadrzędny i usunięte rekordy – świeży <code>xmin</code>, z transakcji z incydentu. Rekord, który nie został usunięty – <code>xmin</code> sprzed setek milionów transakcji. Czyli nikt tego wiersza nie dotykał od bardzo dawna. A więc <code>UPDATE</code> nie został nadpisany przez inną transakcję – Postgres po prostu nigdy go nie dostał. Za to w tej samej transakcji dostał i zatwierdził wiersz outboxa z eventem o usunięciu tego rekordu.</p>
<p>„Łącząc kropki” i wykluczając błąd w kodzie lub w bazie danych, zacząłem podejrzewać Hibernate’a. Z pomocą AI trafiłem na ciekawy fragment w <code>beforeTransactionCompletion()</code>. Ale po kolei – najpierw: kiedy Hibernate w ogóle wysyła zmiany do bazy?</p>
<h2 id="jak-hibernate-zapisuje-zmiany">Jak Hibernate zapisuje zmiany</h2>
<p>Jeśli wiesz, jak działa persistence context i kiedy Hibernate robi flush, tę sekcję możesz pominąć – dalej jest <a href="https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/#kiedy-hibernate-pomija-flush-przy-commicie">sekcja <span>1.4</span></a>.</p>
<p>W wielkim skrócie – Hibernate (jeśli może) trzyma zmiany Entity w “persistence context” i wysyła je do bazy dopiero przy flushu. Flush wykonywany jest przed zapytaniem, które mogłoby te zmiany przeczytać, i zawsze tuż przed commitem (a przynajmniej tak myślałem).</p>
<p>Nie wszystko jednak czeka na flush. Od razu do bazy lecą<sup><a href="https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/#sn-1">1</a></sup>:</p>
<aside id="sn-1"><span>1</span> Zakładamy domyślny tryb flushu w Hibernate, <code>AUTO</code>.</aside>
<ul>
<li><code>INSERT</code> Entity z id <code>IDENTITY</code>, bo id nadaje baza</li>
<li>ręcznie pisane update/delete w JPQL albo natywnym SQL</li>
<li>wszystko, co idzie zwykłym JDBC</li>
<li>zmiany, które wysłał już wcześniejszy flush</li>
</ul>
<p>Cała reszta – zmienione pola Entity, <code>remove()</code>, nowe Entity z sekwencją – czeka w pamięci na flush. Czyli gdyby flush przy commicie się nie wykonał, w bazie zostałoby tylko to, co poleciało do niej wcześniej.</p>
<p>Ten ostatni punkt tłumaczy, czemu u nas zginął akurat <strong>ostatni</strong> rekord. Hibernate robi flush nie tylko przy commicie, ale też przed zapytaniem, jeśli to zapytanie czyta tabelę, w której coś czeka na zapis. Nasz kod wyglądał mniej więcej tak:</p>
<div><div><figure><figcaption></figcaption><pre><code>for (Long recordId : recordIds) {
    Record record = recordRepository.findActiveById(recordId); // zapytanie do tabeli record
    record.setDeleted(true);                                     // czeka w pamięci
    outbox.save(new RecordDeletedEvent(recordId));               // JDBC, leci od razu
}</code></pre></figure></div></div>
<p>I teraz krok po kroku, dla trzech rekordów:</p>
<ol>
<li>Zapytanie o rekord 1 – nic jeszcze nie czeka, więc nie ma flusha. Rekord 1 dostaje <code>deleted = true</code> w pamięci, event idzie do outboxa.</li>
<li>Zapytanie o rekord 2 czyta tabelę <code>record</code>, a w niej czeka zmiana rekordu 1 – więc Hibernate najpierw robi flush i <code>UPDATE</code> rekordu 1 idzie do bazy. Potem to samo z rekordem 2 i eventem.</li>
<li>Zapytanie o rekord 3 wypycha w ten sam sposób zmianę rekordu 2. Rekord 3 zostaje oznaczony w pamięci, event idzie do outboxa.</li>
<li>Koniec pętli. Nie ma już żadnego zapytania o rekordy, więc zmiana rekordu 3 czeka na flush przy commicie…</li>
<li>…który się nie wykonuje. Do bazy trafiają eventy wszystkich trzech rekordów, ale <code>UPDATE</code> tylko dwóch pierwszych.</li>
</ol>
<p>Dokładnie to, co pokazał <code>xmin</code>.</p>
<aside>
<p>Notatka</p>
<p><code>findById()</code> działa inaczej: pod spodem to <code>em.find()</code>, czyli ładowanie Entity po id, a ono nigdy nie robi flusha – nawet gdy idzie do bazy po <code>SELECT</code>. Gdyby nasza pętla używała <code>findById()</code>, przy commicie zginęłyby zmiany <strong>wszystkich</strong> rekordów, a nie tylko ostatniego.</p>
</aside>
<p>Zostaje pytanie: czemu flush przy commicie się nie wykonał?</p>
<h2 id="kiedy-hibernate-pomija-flush-przy-commicie">Kiedy Hibernate pomija flush przy commicie</h2>
<p>I tu wraca <code>beforeTransactionCompletion()</code>. Przy commicie woła ono <code>managedFlush()</code>, a ten sprawdza, czy sesja jest otwarta. Tak to wygląda w Hibernate 7.4.5:</p>
<div id="lst-sessionimpl-java"><div><figure><figcaption><span>SessionImpl.java</span></figcaption><pre><code>private void managedFlush() {
    if ( !isOpenOrWaitingForAutoClose() ) {
        SESSION_LOGGER.skippingAutoFlushSessionClosed(); // TRACE
    }
    else {
        SESSION_LOGGER.automaticallyFlushingSession();
        fireFlush();
    }
}


// inherited from AbstractSharedSessionContract
public boolean isOpenOrWaitingForAutoClose() {
    return !closed &#x26;&#x26; factory.isOpen()
        || waitingForAutoClose;
}</code></pre></figure></div></div>
<p>Gdy fabryka jest zamknięta, każda jej sesja liczy się jako zamknięta, więc <code>managedFlush()</code> loguje <code>Skipping auto-flush since the session is closed</code> na TRACE<sup><a href="https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/#sn-2">2</a></sup> i wraca, a <code>Connection.commit()</code> leci do Postgresa, który zatwierdza wszystko, co dostał. Co gorsze – nic nie rzuca wyjątku, jedynym śladem jest ten jeden log na poziomie TRACE (!).</p>
<aside id="sn-2"><span>2</span> Hibernate 6.6 ma to samo sprawdzenie i loguje <code>Skipping auto-flush due to session closed</code>.</aside>
<p>Do odtworzenia nie trzeba ani Springa, ani nawet drugiego wątku. W teście niżej <code>Payment</code> ma id <code>IDENTITY</code>, więc jego <code>INSERT</code> leci przy <code>persist()</code>, a nowy status zamówienia czeka na flush. Zamykamy fabrykę, robimy commit i… płatność jest, a zamówienie dalej <code>NEW</code>.</p>
<div id="lst-sessionfactoryclosedbeforecommittest-java"><div><figure><figcaption><span>SessionFactoryClosedBeforeCommitTest.java</span><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/plainhibernate/SessionFactoryClosedBeforeCommitTest.java"><span>GitHub</span><span> — SessionFactoryClosedBeforeCommitTest.java w repozytorium, otwiera się w nowej karcie</span></a></figcaption><pre><code>@Test
void closeAndCommitOnTheSameThreadAlsoSaveOnlyPartOfTheTransaction() throws Exception {
    Session session = sessionFactory.openSession();
    Transaction transaction = session.beginTransaction();
    Payment payment = new Payment();
    payment.orderId = 1L;
    session.persist(payment);
    session.find(Order.class, 1L).status = "PAID";


    sessionFactory.close();
    transaction.commit();
    session.close();


    assertEquals(1, count("select count(*) from payments where orderId = 1"), "the INSERT was committed");
    assertEquals("NEW", status(), "the UPDATE was never executed, yet the transaction was committed");
}</code></pre></figure></div></div>
<p>Odtworzyłem to na Hibernate 6.6.53, 6.6.59, 7.4.5 i 7.4.13 z PostgreSQL oraz na 7.4.13 i 8.0.0.Beta3 z H2 (na szablonie testów od Hibernate’a). Starszych wersji niż 6.6 nie sprawdzałem.</p>
<p>Zostaje pytanie: kto zamyka fabrykę w środku transakcji?</p>
<h2 id="co-się-dzieje-przy-sigterm">Co się dzieje przy SIGTERM</h2>
<p>Pamiętasz, że autoscaling akurat zatrzymywał poda? Po otrzymaniu <code>SIGTERM</code> Spring Boot zamyka się w dwóch etapach. Najpierw (faza SmartLifecycle) zatrzymuje to, co może tworzyć nowe zadania: endpointy HTTP, listenery/konsumery itp. + daje im ograniczony czas na dokończenie pracy (domyślnie: kontener Kafki czeka 10 s, RabbitMQ 5 s, a każda faza zamykania w Spring Boot maksymalnie 30 s). Potem niszczy beany, w tym nasze <code>EntityManagerFactory</code>, a na końcu <code>DataSource</code>.</p>
<p>Problem w tym, że drugi etap zakłada, że pierwszy wszystko zakończył, a handler, który czeka na wolne zewnętrzne API, <strong>może się nie zmieścić w czasie</strong>. Spring, mimo że wątek dalej działa, idzie do kolejnej fazy i zaczyna niszczyć beany. Wątek w końcu dostaje odpowiedź, wychodzi z metody <code>@Transactional</code>, a Hibernate robi commit bez flusha, bo <code>EntityManagerFactory</code> jest już zamknięte.</p>
<p>Pokażę to na <a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown">aplikacji <span>demo<span> (GitHub, otwiera się w nowej karcie)</span></span></a>: mały sklep, w którym jedna metoda <code>@Transactional</code> pobiera zamówienie, zapisuje płatność, oznacza zamówienie jako opłacone i woła wolne sprawdzenie pod kątem oszustwa (tak, normalnie rozdzielilibyśmy to na kilka wiadomości, ale to dobry przykład, żeby pokazać problem z Hibernate’em). Poniżej ta transakcja krok po kroku – w kroku 4, w trakcie czekania, Spring zamyka fabrykę. Przełącznik strażnika (o nim później) na razie zostaw wyłączony.</p>
<p><a href="https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/#demo-co-trafia-do-postgresa-od-razu-a-co-czeka-w-pamięci-hibernate-a">Interaktywne demo w poście: Co trafia do Postgresa od razu, a co czeka w pamięci Hibernate’a</a></p>
<p>A tak to wygląda w czasie – wątek roboczy i zamykający się Spring obok siebie:</p>
<p><a href="https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/#demo-wątek-roboczy-np-kafka-consumer-podczas-otrzymania-sygnału-sigterm">Interaktywne demo w poście: Wątek roboczy (np. Kafka consumer) podczas otrzymania sygnału SIGTERM</a></p>
<p>Co zabawne, przy <code>SIGKILL</code> połączenie się zrywa i Postgres robi rollback. Częściowy commit wymaga właśnie „grzecznego” (<code>SIGTERM</code>) zamknięcia.</p>
<h2 id="może-to-jakiś-błąd-tylko-w-naszej-aplikacji">Może to jakiś błąd tylko w naszej aplikacji?</h2>
<p>Początkowo myślałem, że to błąd konfiguracji w naszym serwisie, więc <a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown">zbudowałem czystą aplikację Spring <span>Boot<span> (GitHub, otwiera się w nowej karcie)</span></span></a> + Spring JPA + Spring Kafka/AMQP. Wynik – zaskakujący. Tam też ten błąd odtworzyłem, co oznacza, że może to dotyczyć każdej aplikacji (przy odrobinie [nie]szczęścia).</p>
<p>Repo to ten sam sklep co w demach wyżej: Spring Boot 4 + Hibernate 7 + Postgres, ze Spring MVC, Kafką i RabbitMQ. Każdy test wpuszcza płatność innym wejściem, zatrzymuje poda w trakcie sprawdzenia, a na koniec zagląda do bazy, żeby zweryfikować wynik.</p>
<p>Jedna rzecz jest tu celowo „oszukana”: bean <code>MetricsExporter</code> zamyka się 0.5 s i jest wpięty tak, że Spring niszczy go między Hibernate’em a pulą. To rozciąga okno (o nim w kolejnej sekcji) z ~1.2 ms do pół sekundy, więc testy trafiają w nie za każdym razem i są deterministyczne. Reszta – Spring, Hibernate, HikariCP, kontenery listenerów – działa bez zmian. Test, który mierzy samo okno czasowe, działa już bez tego triku – taki też mamy :)</p>
<p>Wyniki (kolumna „Ze strażnikiem” będzie jasna za dwie sekcje):</p>








































<div><table><thead><tr><th>Scenariusz</th><th>Bez strażnika</th><th>Ze strażnikiem</th></tr></thead><tbody><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/KafkaPaymentShutdownTest.java"><span><code>@KafkaListener</code><span> (GitHub, otwiera się w nowej karcie)</span></span></a></td><td>płatność zapisana, zamówienie <code>NEW</code>, zgłoszony sukces, brak błędu w logu</td><td>rollback, nowy pod przetwarza wiadomość w całości</td></tr><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/RabbitMqPaymentShutdownTest.java"><span><code>@RabbitListener</code><span> (GitHub, otwiera się w nowej karcie)</span></span></a></td><td>płatność zapisana, zamówienie <code>NEW</code>, wiadomość policzona jako przetworzona</td><td>rollback, wiadomość wraca do kolejki</td></tr><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/HttpRequestShutdownTest.java">Żądanie <span>HTTP<span> (GitHub, otwiera się w nowej karcie)</span></span></a></td><td>płatność zapisana, zamówienie <code>NEW</code>, wołający nie dostaje odpowiedzi</td><td>nic nie zapisane, wołający może bezpiecznie ponowić</td></tr><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/AsyncTaskShutdownTest.java">Zadanie <span><code>@Async</code><span> (GitHub, otwiera się w nowej karcie)</span></span></a></td><td>płatność zapisana, zamówienie <code>NEW</code>, dostawca dostał już <code>202</code></td><td>nic nie zapisane do połowy, ale dostawca nie wyśle ponownie</td></tr><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/ScheduledJobShutdownTest.java">Zadanie <span><code>@Scheduled</code><span> (GitHub, otwiera się w nowej karcie)</span></span></a></td><td>płatność zapisana, zamówienie <code>NEW</code>, brak błędu w logu</td><td>rollback, nic nie zapisane</td></tr><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/PaymentShutdownStory.java">Log samego Postgresa i <span><code>xmin</code><span> (GitHub, otwiera się w nowej karcie)</span></span></a>, Kafka i RabbitMQ</td><td><code>INSERT</code> i <code>COMMIT</code> dotarły, <code>UPDATE</code> nigdy</td><td><code>ROLLBACK</code> zamiast <code>COMMIT</code></td></tr></tbody></table></div>
<p>Do odpalenia testów potrzebujesz Dockera i Javy 21+:</p>
<div id="lst-terminal"><div><figure><figcaption><span>Terminal</span></figcaption><pre><code>$ git clone https://github.com/Mixonn/hibernate-partial-commit-on-shutdown.git
$ cd hibernate-partial-commit-on-shutdown
$ mvn -B test -Dtest=KafkaPaymentShutdownTest</code></pre></figure></div></div>
<h2 id="okno-12-ms">Okno 1.2 ms</h2>
<p>Jest jeden warunek. Commit musi wypaść po zamknięciu Hibernate’a, ale przed zamknięciem puli połączeń. Jak pula zamknie już połączenie, commit się nie uda i Postgres zrobi rollback.</p>
<p>W zwykłej aplikacji Spring Boot 4.1 z HikariCP ta przerwa trwa ok. 1.2 ms (<a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/window/ShutdownWindowMeasurement.java#L40">sprawdź <span>sam<span> (GitHub, otwiera się w nowej karcie)</span></span></a>), mierzone na laptopie z lokalną bazą<sup><a href="https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/#sn-3">3</a></sup>. Jeśli <code>COMMIT</code> wyjdzie w pierwszych ~0.5 ms okna, kończy się cichym częściowym commitem. Później <code>COMMIT</code> dalej dociera do Postgresa, ale HikariCP zamyka socket, zanim przyjdzie odpowiedź, i aplikacja dostaje <code>Socket closed</code>. Czyli dane zapisane do połowy + błąd, który zachęca do ponowienia czegoś, co jest już w połowie zrobione. Możesz przeciągnąć commit na demo niżej i zobaczyć, gdzie co się dzieje.</p>
<aside id="sn-3"><span>3</span> 160 zamknięć, każde w świeżej JVM, z Hibernate 7.4.5, HikariCP 7.0.2 i Postgresem 16 w Dockerze. Mediana wyniosła 1.17 ms, najdłużej 5 ms.</aside>
<p><a href="https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/#demo-1-2-ms-między-zamknięciem-hibernate-a-a-zamknięciem-puli">Interaktywne demo w poście: 1.2 ms między zamknięciem Hibernate’a a zamknięciem puli</a></p>
<p>Niby niewiele, ale pody zamykane są często, a okno może się wydłużyć przy ograniczonym CPU, wolnej sieci itp.</p>
<p>Czemu nikt tego nie zauważa? Po pierwsze, przy krótkich transakcjach dzieje się to niezwykle rzadko. Po drugie, nawet jeśli się zdarzy, kod biznesowy widzi udany commit, a w drugiej części okna – błąd połączenia przy zamykaniu poda (czyli coś, co jest „standardowe”). Jedyny log, który mówi prawdę, jest na poziomie TRACE. Czyli go nie ma.</p>
<p>Zostają niespójne dane. My zauważyliśmy swoje tylko dzięki temu eventowi na DLT. Bez tego wiersz dalej by sobie leżał, a ewentualne niezgodności uznalibyśmy za „cannot reproduce” albo „pewnie kiedyś kod miał buga, teraz wszystko wygląda OK”.</p>
<p>Nasz przypadek nie był krytyczny, ale co w sytuacji, w której do akcji wkraczają pieniądze, albo jakieś krytyczne reguły biznesowe u ważnych klientów? Wg mnie to niedopuszczalne zachowanie Hibernate’a.</p>
<h2 id="jak-się-przed-tym-zabezpieczyć">Jak się przed tym zabezpieczyć</h2>
<p>Obejściem jest strażnik, który nie pozwala na commit, gdy fabryka jest już zamknięta. W każdej transakcji rejestruje <code>Synchronization</code>, którą Hibernate woła po flushu (albo jego pominięciu), a przed <code>Connection.commit()</code>. Jeśli fabryka jest już zamknięta, strażnik rzuca wyjątek i Hibernate robi rollback.</p>
<p>W Springu najprościej zrobić z niego <code>TransactionExecutionListener</code> – wystarczy jeden <code>@Component</code>, Spring Boot sam podpina takie beany do menedżera transakcji, a <code>afterBegin</code> wykonuje się na początku każdej transakcji. Cały kod pokazuje <a href="https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/#lst-partialcommitguard-java">listing <span>1.4</span></a>.</p>
<div id="lst-partialcommitguard-java"><div><figure><figcaption><span>PartialCommitGuard.java</span><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/17f45627c180e969c29336f45bc95308737f425b/src/main/java/fix/PartialCommitGuard.java"><span>GitHub</span><span> — PartialCommitGuard.java w repozytorium, otwiera się w nowej karcie</span></a></figcaption><pre><code>@Component
public class PartialCommitGuard implements TransactionExecutionListener {


    private static final Logger log = LoggerFactory.getLogger(PartialCommitGuard.class);


    /** Called at the start of every transaction. */
    @Override
    public void afterBegin(TransactionExecution transaction, Throwable beginFailure) {
        if (beginFailure != null || !transaction.isNewTransaction()) {
            return;
        }
        try {
            for (Object resource : TransactionSynchronizationManager.getResourceMap().values()) {
                if (resource instanceof EntityManagerHolder holder) {
                    Session session = holder.getEntityManager().unwrap(Session.class);
                    session.getTransaction().registerSynchronization(new Guard(session));
                }
            }
        } catch (RuntimeException e) {
            // Never throw from here: it would break the start of the transaction.
            log.warn("Could not add the guard to the transaction", e);
        }
    }


    /** The guard. Hibernate calls beforeCompletion() right before committing the transaction. */
    static final class Guard implements Synchronization {


        private final Session session;


        Guard(Session session) {
            this.session = session;
        }


        @Override
        public void beforeCompletion() {
            if (session.getSessionFactory().isClosed() &#x26;&#x26; !readOnly()) {
                throw new HibernateClosedBeforeCommitException();
            }
        }


        @Override
        public void afterCompletion(int status) {
        }


        /** In read-only transactions Hibernate writes nothing anyway, so there is nothing to guard. */
        private boolean readOnly() {
            try {
                return session.getHibernateFlushMode() == FlushMode.MANUAL;
            } catch (RuntimeException e) {
                return false;
            }
        }
    }


    public static final class HibernateClosedBeforeCommitException extends IllegalStateException {


        HibernateClosedBeforeCommitException() {
            super("Hibernate was closed before the transaction was committed: rolling it back instead of saving only part of the data");
        }
    }
}</code></pre></figure></div></div>
<p>Na diagramie niżej widać, gdzie dokładnie strażnik wchodzi w commit.</p>
<p><a href="https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/#fig-wnętrze-commitu-bez-strażnika-i-ze-strażnikiem">Rysunek w poście: Wnętrze commitu bez strażnika i ze strażnikiem</a></p>
<p>Puściłem też test, który zamykał fabrykę w różnych momentach wokół commitu – 1000 przebiegów. Bez strażnika 2–4% transakcji zapisało się częściowo, ze strażnikiem żadna.</p>
<p>Zamknięta fabryka nigdy się ponownie nie otwiera, a strażnik działa już po decyzji o flushu, więc pominięty flush zawsze na niego trafi. W drugą stronę może się pomylić: jeśli fabryka zamknie się tuż po pełnym flushu, strażnik wycofa transakcję, która była OK.</p>
<p>Decyzja należy do Ciebie<sup><a href="https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/#sn-4">4</a></sup> – czy wolisz częściowo zapisaną transakcję, czy wycofanie takiej, która mogła się jeszcze udać tuż przed zamknięciem poda?</p>
<aside id="sn-4"><span>4</span> Tak naprawdę nie należy do Ciebie – decyzja jest już podjęta: rollback :) Wycofaną transakcję da się bezpiecznie powtórzyć (wiadomość wraca do kolejki, klient HTTP ponawia żądanie), a częściowego commitu nikt nie zauważy i nikt nie naprawi.</aside>
<aside>
<p>Ostrożnie</p>
<p>Strażnik nie obejmuje JTA ani ręcznie tworzonych entity managerów.</p>
</aside>
<p>Możesz też wrócić do demka z sekcji o SIGTERM i włączyć w nim strażnika.</p>
<h2 id="skąd-to-się-wzięło-i-co-dalej">Skąd to się wzięło i co dalej</h2>
<p>W 2013 roku pojawiło się zgłoszenie <a href="https://hibernate.atlassian.net/browse/HHH-8586"><span>HHH-8586<span> (otwiera się w nowej karcie)</span></span></a>: commit JTA po <code>EntityManagerFactory.close()</code> odpalał flush, który rzucał wyjątek. Poprawka (<a href="https://github.com/hibernate/hibernate-orm/commit/fa6a222ab1dd9925d95f211f38c951e0db4095ad"><span><code>fa6a222</code><span> (GitHub, otwiera się w nowej karcie)</span></span></a>, wersja 4.3.0.Beta5) zmieniła <code>isClosed()</code> na <code>closed || factory.isClosed()</code> i od tamtej pory flush po zamknięciu fabryki jest po cichu (tak, log TRACE) pomijany, zamiast kończyć się wyjątkiem i rollbackiem. W testach do tej poprawki nie było niczego do flushowania, więc nie było też czego „zgubić”, a w zgłoszeniu nikt o tym nie wspomina. Obstawiam więc, że utrata danych to przeoczenie, a nie czyjaś decyzja.</p>
<p>Co ciekawe, prawie każde inne wywołanie na sesji po zamknięciu fabryki przechodzi przez <code>checkOpen()</code>. Gdyby w drodze do commitu było jeszcze jedno <code>persist()</code>, dostałbyś <code>IllegalStateException: Session/EntityManager is closed</code> i czysty rollback. Tylko flush przy commicie po cichu pomija swoją pracę i idzie dalej.</p>
<p>A co na to JPA? Specyfikacja (Jakarta Persistence 3.2) tego przypadku wprost nie opisuje: <code>EntityManagerFactory.close()</code> mówi, że po zamknięciu fabryki wszystkie jej entity managery są zamknięte, a <code>EntityTransaction.commit()</code> ma „writing any unflushed changes to the database” i <code>RollbackException</code>, gdy commit się nie uda. Dla mnie commit, który nie może zapisać zmian, to nieudany commit. Hibernate wypełnia tę lukę – tylko akurat po tej stronie, po której giną dane.</p>
<p>Zgłosiłem to jako <a href="https://hibernate.atlassian.net/browse/HHH-21038"><span>HHH-21038<span> (otwiera się w nowej karcie)</span></span></a> (Improvement, 22 października 2026), w chwili pisania dalej ma status New. Zaproponowałem, żeby <code>managedFlush()</code> rzucał wyjątek, jeśli sesja może mieć jeszcze jakieś zmiany (zakolejkowane akcje albo zarządzane Entity), a cichy commit zostawał tylko wtedy, gdy nie ma czego flushować – czyli przypadek z HHH-8586. Jeśli to nie przejdzie, to przynajmniej logować na WARN/ERROR zamiast TRACE.</p>
<p>Oba reproduktory są publiczne: <a href="https://github.com/Mixonn/hibernate-test-case-templates/tree/partial-commit-after-sessionfactory-close">z H2 na szablonie testów <span>Hibernate’a<span> (GitHub, otwiera się w nowej karcie)</span></span></a> (commit <code>96be59a</code>) i <a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/plainhibernate/SessionFactoryClosedBeforeCommitTest.java">z <span>PostgreSQL<span> (GitHub, otwiera się w nowej karcie)</span></span></a>.</p>
<h2 id="co-muszę-teraz-zrobić">Co muszę teraz zrobić?</h2>
<p>Nie panikuj. Ten przypadek występuje tylko wtedy, kiedy jakiś wątek działa dłużej, niż pozwala mu na to SmartLifecycle, a okno czasowe w zwykłej aplikacji to ok. 1.2 ms (w serwisach na produkcji raczej dłużej).</p>
<p>Ogólnie warto trzymać transakcje krótkie. Wolne zewnętrzne API, HTTP do innego serwisu, długie obliczenia – lepiej przed transakcją albo po niej, a w środku tylko zapis do bazy. Do tego timeouty na takich wywołaniach krótsze niż czas, jaki Spring daje na zamknięcie.</p>
<p>Tylko że ludzie popełniają błędy. Ktoś doda wywołanie innego serwisu „na chwilę”, ktoś nie zauważy, że metoda wyżej jest <code>@Transactional</code>, timeout zostanie domyślny (czyli często nieskończony<sup><a href="https://bartoszosipiuk.pl/blog/hibernate-lost-my-update/#sn-5">5</a></sup>), albo zupełnie inny bug – deadlock, wolne zapytanie, zawieszony klient – przytrzyma wątek dłużej, niż zakładaliśmy. I wtedy i tak nadziejemy się na ten problem. Dlatego najlepszą ochroną dalej jest strażnik.</p>
<aside id="sn-5"><span>5</span> Np. <code>RestClient</code> i <code>RestTemplate</code> w Spring Boot 4.1: bez <code>spring.http.clients.read-timeout</code> czekają na odpowiedź bez limitu, bo domyślne ustawienia klienta HTTP w Boocie nie mają ani <code>connectTimeout</code>, ani <code>readTimeout</code>. Sam <code>@Transactional</code> też domyślnie nie ma timeoutu.</aside>
<p>Co więc zrobić?</p>
<ol>
<li>Sprawdź w metrykach, ile trwają Twoje transakcje – każda dłuższa to potencjalny kandydat do tego błędu.</li>
<li>Wyciągnij z transakcji wywołania zewnętrznych API, HTTP do innych serwisów i długie operacje. Tam, gdzie się nie da, ustaw timeouty krótsze niż czas zamykania.</li>
<li>Wdróż <a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/17f45627c180e969c29336f45bc95308737f425b/src/main/java/fix/PartialCommitGuard.java"><span>strażnika<span> (GitHub, otwiera się w nowej karcie)</span></span></a>. Jeśli Twoje transakcje trwają długo – nie zastanawiałbym się długo i wdrażał. Jeśli są krótkie – bez pośpiechu, ale nadal warto wdrożyć i nie ryzykować straceniem części transakcji.</li>
<li>Obserwuj <a href="https://hibernate.atlassian.net/browse/HHH-21038"><span>HHH-21038<span> (otwiera się w nowej karcie)</span></span></a> – jeśli Hibernate to naprawi, strażnika można usunąć.</li>
</ol>
<p>„Częściowy commit”, na dodatek cichy, bez błędu w logach, to jeden z największych problemów, jakie mogą się nam przytrafić. Nie znajdziemy tego błędu w kodzie, dane będą niespójne, a na dodatek nikt tego nie zauważy.</p>
<p><img src="https://bartoszosipiuk.pl/_astro/mem-anakin-padme.Bv0kgJJY_ZRI7iu.webp" alt="Mem z Anakinem i Padmé. Anakin: „Commit przeszedł bez błędu”. Padmé z uśmiechem: „I wszystko zostało zapisane, prawda?”. Anakin milczy. Padmé, już bez uśmiechu: „I wszystko zostało zapisane, prawda?”"></p>]]></content:encoded></item></channel></rss>