Transakcje w mendix — co naprawdę dzieje się przy commicie

Transakcje to jeden z tych tematów, które w klasycznym programowaniu każdy zna z bazodanowych podstaw (BEGIN, COMMIT, ROLLBACK), a w Mendix bywają owiane pewną mgłą — bo platforma dużo robi „za nas”, co jest wygodne do czasu, aż trzeba zrozumieć, co dokładnie się dzieje pod spodem.

Mikroprzepływ jako granica transakcji

W Mendix domyślną jednostką transakcyjną jest zazwyczaj mikroprzepływ (a dokładniej — commit wykonywany w jego trakcie). Kiedy w mikroprzepływie wywołujesz akcję „Commit” na obiekcie, uruchamiasz cykl zdarzeń (Before commit, sam zapis do bazy, After commit), o którym pisałem już przy okazji artykułu o zdarzeniach encji. To, co jest łatwe do przeoczenia: jeśli w jednym mikroprzepływie commitujesz kilka różnych obiektów w osobnych krokach, niekoniecznie dzieje się to w ramach jednej, atomowej transakcji bazodanowej — zależy to od tego, jak dokładnie ułożona jest logika.

Praktyczna konsekwencja: jeśli zależy Ci na tym, żeby zestaw zmian albo zapisał się w całości, albo wcale, musisz świadomie zaprojektować mikroprzepływ tak, żeby commit obejmował wszystkie powiązane obiekty naraz (np. przez commitowanie obiektu wraz z jego skojarzeniami), a nie polegać na założeniu, że „cały mikroprzepływ to jedna transakcja” — bo to założenie bywa błędne.

Kiedy naprawdę zaczyna się transakcja

Jest jeszcze jedno powszechne błędne założenie, które warto rozbroić: większość developerów zakłada, że transakcja bazodanowa startuje w momencie uruchomienia mikroprzepływu. W praktyce Mendix otwiera fizyczną transakcję w bazie danych leniwie — dopiero przy pierwszej operacji faktycznie zapisującej dane (typowo pierwszym Commit, choć w zależności od źródła również pierwszy Retrieve-from-db, Delete czy Create-commit może ją zainicjować). Dopóki mikroprzepływ wykonuje wyłącznie logikę w pamięci — walidacje, obliczenia, wywołania REST, mapowania — żadna transakcja w bazie jeszcze nie istnieje.

Ma to konkretne konsekwencje projektowe. Transakcja bazodanowa oznacza zajęte połączenie z puli (connection pool) oraz, w zależności od operacji, blokady na wierszach — im dłużej trwa otwarta transakcja, tym dłużej te zasoby są zablokowane dla innych żądań. Jeśli mikroprzepływ najpierw commituje obiekt, a dopiero potem wykonuje czasochłonne operacje niezwiązane z bazą (np. wolne wywołanie zewnętrznego API, złożone obliczenia), to transakcja pozostaje otwarta przez cały ten czas — mimo że logicznie nie ma już nic więcej do zapisania.

Praktyczna zasada projektowa: commit powinien znajdować się możliwie blisko końca mikroprzepływu, a nie na jego początku czy w środku. Kolejność, do której warto dążyć, to: najpierw cała logika niezwiązana z zapisem (walidacje, wywołania zewnętrznych API, obliczenia, przygotowanie danych), a dopiero na samym końcu — commit. Dzięki temu fizyczna transakcja w bazie otwiera się później i trwa krócej, co bezpośrednio przekłada się na mniejsze obciążenie puli połączeń i mniejsze ryzyko blokad przy równoległym ruchu.

To też dodatkowy argument przeciwko wywoływaniu wolnego REST API „w środku” mikroprzepływu, pomiędzy dwoma commitami — jeśli już wcześniejszy commit otworzył transakcję, każda kolejna sekunda oczekiwania na odpowiedź zewnętrznego systemu to sekunda, w której transakcja bazodanowa stoi otwarta bez potrzeby.

Rollback nie zawsze oznacza to, czego się spodziewasz

Tu warto rozwiać nieporozumienie, w które łatwo wpaść, przenosząc intuicję wprost ze świata klasycznych baz danych. Aktywność Commit w mikroprzepływie nie jest tym samym, co SQL COMMIT. Mendix zapisuje wartość obiektu i tworzy tzw. savepoint, ale rzeczywisty, fizyczny zapis do bazy następuje dopiero wtedy, gdy zakończy się cały mikroprzepływ — łącznie z każdym mikroprzepływem, który go wywołał, aż do najwyższego poziomu wywołania.

Konsekwencja: jeśli w głównym mikroprzepływie skomitujesz obiekt Order, a chwilę później wywołany sub-mikroprzepływ rzuci błąd z domyślną obsługą błędów (Rollback), to domyślnie cała transakcja — łącznie z wcześniejszym commitem Order — zostanie cofnięta do ostatniego savepointu, mimo że z perspektywy mikroprzepływu wyglądało to na „zakończony” zapis. To odwrotność intuicji ze świata klasycznych baz danych, gdzie COMMIT jest operacją natychmiastową i nieodwracalną.

To źródło błędów, które potrafi zaskoczyć nawet doświadczonych deweloperów: intuicja podpowiada „ten wcześniejszy zapis jest już bezpieczny”, a w rzeczywistości cały czas czeka na finalne zamknięcie transakcji na najwyższym poziomie wywołania.

StartTransaction w Java Action a CommitInSeparateTransactions / Core.createSystemContext()

Skoro commit w mikroprzepływie nie jest niezależny od reszty drzewa wywołań, pojawia się naturalne pytanie: jak wymusić, żeby konkretny zapis przetrwał niezależnie od tego, co stanie się później? Tu dwa pozornie podobne rozwiązania działają zupełnie inaczej.

context.startTransaction() / context.endTransaction() na tym samym kontekście (np. w customowym Java Action) nie tworzy niezależnej transakcji. Jeśli w bieżącym wątku transakcja już trwa — a w mikroprzepływie zawsze trwa — wywołanie startTransaction() po prostu dokłada kolejny savepoint do tej samej, nadrzędnej transakcji. endTransaction() zamyka ten konkretny savepoint, ale rzeczywisty zapis do bazy nadal czeka na zakończenie całego drzewa wywołań. To wciąż ten sam wątek, ten sam kontekst i ta sama, ostateczna transakcja bazodanowa — tylko z dodatkowym punktem, do którego można się cofnąć.

Core.createSystemContext() działa inaczej — tworzy zupełnie nowy, niezależny kontekst sesji systemowej, a nie zagnieżdżony savepoint w istniejącej transakcji. Commitując obiekt przy użyciu tego kontekstu, zapisujesz go do bazy niezależnie od tego, co wydarzy się później w mikroprzepływie, który go wywołał. Podobny efekt daje community’owa akcja commitInSeparateDatabaseTransaction z modułu Community Commons — jawnie tworzy osobny kontekst i transakcję wyłącznie po to, żeby zagwarantować zapis obiektu do bazy niezależnie od dalszego przebiegu wywołującego mikroprzepływu. Warto jednak pamiętać o zastrzeżeniu z dokumentacji tej akcji: może prowadzić do zakleszczeń (deadlock), jeśli ten sam obiekt jest jednocześnie modyfikowany w innej, wciąż otwartej transakcji — dlatego Mendix wprost odradza jej użycie bez pełnego zrozumienia konsekwencji.

Przykład: kiedy jeden commit przetrwa, a drugi nie

Załóżmy mikroprzepływ główny CreateOrderAndInvoice:

  1. Tworzy obiekt Order i wykonuje na nim Commit.
  2. Wywołuje sub-mikroprzepływ SUB_CreateInvoice — z obsługą błędu na tym wywołaniu ustawioną na „Custom without Rollback”.
  3. Wewnątrz SUB_CreateInvoice: tworzy obiekt Invoice, commituje go, a następnie wywołuje zewnętrzne REST API, które kończy się błędem.

Bez żadnej specjalnej konfiguracji (domyślny Rollback wszędzie) błąd w kroku 3 cofnąłby całą transakcję do samego początku — łącznie z commitem Order z kroku 1. To dokładnie ten scenariusz: domyślnie commit w mikroprzepływie nadrzędnym również zostaje wycofany.

Różnicę robi ustawienie z kroku 2. „Custom without Rollback” na wywołaniu sub-mikroprzepływu tworzy nową subtransakcję (transaction boundary) w tym miejscu drzewa wywołań. Wszystko, co zostało skomitowane w mikroprzepływie nadrzędnym przed tym wywołaniem — czyli Order — jest od tego momentu oddzielone i zostanie trwale zapisane, niezależnie od tego, co stanie się w SUB_CreateInvoice. To, co dzieje się wewnątrz sub-mikroprzepływu, podlega już jego własnej logice błędów: przy domyślnej obsłudze błędów (Rollback) wewnątrz SUB_CreateInvoice, commit Invoice zostanie wycofany do savepointu wewnątrz tego sub-mikroprzepływu, gdy wywołanie REST zawiedzie.

Wynik końcowy: Order trafia do bazy danych, Invoice — nie. Jeden commit przeżywa błąd, drugi nie, mimo że oba wystąpiły w tym samym, jednowątkowym łańcuchu wywołań — różnicę robi wyłącznie ustawienie obsługi błędów na konkretnym wywołaniu sub-mikroprzepływu, a nie osobny wątek czy osobne połączenie do bazy danych.

System context a transakcje przy operacjach masowych

Przy operacjach na dużej liczbie obiektów (np. masowe usuwanie danych czy przetwarzanie wsadowe) pojedyncza, ogromna transakcja obejmująca tysiące rekordów prowadzi wprost do problemów z pamięcią — czego sam doświadczyłem, projektując mechanizm masowego usuwania danych w Mendix. Rozwiązaniem, które się sprawdziło, było rozbicie operacji na mniejsze partie (batch), z osobnymi, izolowanymi commitami wykonywanymi przez Core.createSystemContext() — każda partia jako osobna, mniejsza transakcja, zamiast jednej gigantycznej.

To podejście ma dwie zalety: ogranicza zużycie pamięci (bo nie trzeba trzymać w kontekście transakcji całego ogromnego zbioru naraz) oraz ogranicza skutki ewentualnego błędu — jeśli coś pójdzie nie tak w połowie operacji, tracimy tylko bieżącą partię, a nie całą pracę wykonaną od początku.

Komunikacja z zewnętrznymi systemami a transakcyjność

Jeden z trudniejszych przypadków to sytuacja, w której mikroprzepływ łączy commit obiektu w bazie danych z wywołaniem zewnętrznego API (np. powiadomienia, integracji z systemem partnera). Baza danych i zewnętrzne API nie dzielą tej samej transakcji — nie da się w prosty sposób „wycofać” wywołania REST, tak jak wycofuje się zapis w bazie.

To wymusza świadomą decyzję o kolejności operacji i strategii obsługi błędów: czy najpierw commitować lokalnie, a dopiero potem wywoływać zewnętrzne API (z ryzykiem, że lokalny stan i zewnętrzny się rozjadą, jeśli wywołanie zawiedzie), czy odwrotnie. W praktyce często najbezpieczniejsze jest zaprojektowanie mechanizmu ponawiania (retry) i jawnego oznaczania stanu synchronizacji (np. flagi „wysłano do zewnętrznego systemu”), zamiast liczenia na to, że obie operacje zawsze się powiodą albo obie zawiodą razem.

Before commit jako miejsce na ostatnią walidację

Zdarzenie Before commit to dobre miejsce na walidacje, które muszą zobaczyć finalny stan obiektu tuż przed zapisem — ale warto pamiętać, że logika umieszczona w tym miejscu wykonuje się w ramach tej samej transakcji, więc błąd rzucony tutaj skutecznie zatrzyma cały zapis. To użyteczne dla reguł biznesowych krytycznych dla spójności danych, ale też miejsce, w którym łatwo niechcący spowolnić każdy zapis w systemie, jeśli walidacja jest kosztowna obliczeniowo (np. odpytuje inne encje albo zewnętrzne systemy).

Podsumowanie

Transakcje w Mendix wyglądają na „załatwione za nas” przez platformę, dopóki nie trzeba zaprojektować czegoś bardziej złożonego niż pojedynczy zapis jednego obiektu — operacji masowych, integracji z zewnętrznymi systemami czy wielokrokowych mikroprzepływów z częściowymi commitami. Zrozumienie, gdzie faktycznie przebiega granica transakcji, a gdzie tylko wygląda, że przebiega, to jedna z tych rzeczy, które odróżniają kod działający „zwykle dobrze” od kodu odpornego na brzegowe przypadki

Jeśli temat transakcji w Mendix jest Ci bliski, przygotowałem bezpłatną 3-stronicową ściągę, do której możesz wracać podczas codziennej pracy.

Przewijanie do góry