Legionista Marek przekazuje złoty puchar Gajuszowi. W kodzie to dwie operacje: odjąć puchar z inwentarza Marka, dopisać go Gajuszowi. Wykonujesz pierwszą, a w tej samej chwili pada serwer bazy. Rezultat? Puchar zniknął ze skarbca Imperium. Nie ma go u Marka, nie dotarł do Gajusza - wyparował między dwoma zapisami.
Skarbiec nie może sobie na to pozwolić. Potrzebujemy sposobu, by powiedzieć bazie: "te dwie operacje to jeden rozkaz - wykonaj obie albo żadnej". Tym sposobem jest transakcja.
Właściwość, której szukamy, nazywa się atomowość (ang. atomicity) - transakcja jest niepodzielna jak atom: nie da się jej wykonać w połowie. To pierwsza litera skrótu ACID, którym opisuje się gwarancje transakcji:
Zapamiętaj przede wszystkim to A - reszta z niego wynika i to o nie chodzi w naszym pucharze.
Każda transakcja przechodzi tę samą drogę, niezależnie od bazy i języka. Najpierw BEGIN - ogłaszamy początek i od tej chwili baza zapisuje zmiany na boku, jako nietrwałe. Potem wykonujemy operacje - nasze dwa zapisy. Następnie sprawdzamy, czy wszystko się udało. I na końcu jedna z dwóch dróg: COMMIT zatwierdza całość i dopiero teraz zmiany stają się trwałe, albo ROLLBACK cofa wszystko, jakby transakcji nigdy nie było.
Ten schemat - rozpocznij, wykonaj, zweryfikuj, zatwierdź lub cofnij - warto mieć w głowie, zanim spojrzymy na kod. Cała reszta lekcji to dwa sposoby zapisania go w TypeORM.
Najprostszy wariant to
dataSource.transaction(). Podajesz funkcję, a TypeORM sam otwiera transakcję przed jej wywołaniem i zamyka po zakończeniu:1await this.dataSource.transaction(async (manager) => {
2 await manager.update(Tribute, tributeId, { legionariusze: { id: toLegionaryId } });
3 await manager.increment(Legionary, { id: toLegionaryId }, 'tributeCount', 1);
4 await manager.decrement(Legionary, { id: fromLegionaryId }, 'tributeCount', 1);
5});Funkcja dostaje jeden argument:
manager - odpowiednik repozytorium, ale przypisany do tej konkretnej transakcji. To najważniejszy szczegół tego kodu: wszystkie operacje muszą przechodzić przez manager. Gdybyś w środku sięgnął po zwykłe this.tributeRepository.save(...), ten zapis poszedłby poza transakcją - i nie cofnąłby się przy błędzie.A skąd baza wie, czy zatwierdzić, czy cofnąć? Z wyjątku. Jeśli funkcja dobiegnie do końca spokojnie, TypeORM wykonuje COMMIT. Jeśli cokolwiek w środku rzuci wyjątek - robi ROLLBACK i przekazuje wyjątek dalej. Nie piszesz ani jednej linii obsługi - i to polecam jako domyślny wybór, @name.
Czasem potrzebujesz sterować transakcją ręcznie - na przykład wykonać coś między operacjami albo zareagować na konkretny błąd. Wtedy sięgasz po QueryRunner: obiekt reprezentujący jedno, wyłączne połączenie z bazą, na którym sam wywołujesz kolejne etapy.
1const queryRunner = this.dataSource.createQueryRunner();
2
3await queryRunner.connect();
4await queryRunner.startTransaction();
5
6try {
7 await queryRunner.manager.update(Tribute, tributeId, {
8 legionariusze: { id: toLegionaryId },
9 });
10 await queryRunner.manager.increment(
11 Legionary, { id: toLegionaryId }, 'tributeCount', 1,
12 );
13
14 await queryRunner.commitTransaction();
15} catch (error) {
16 await queryRunner.rollbackTransaction();
17 throw error;
18} finally {
19 await queryRunner.release();
20}Prześledźmy ten kod krok po kroku, bo każda linia odpowiada jednemu etapowi cyklu, który poznałeś wyżej.
createQueryRunner() tworzy obiekt, connect() rezerwuje dla niego połączenie z puli, a startTransaction() to nasz BEGIN. Operacje wykonujemy przez queryRunner.manager - znów ten sam warunek co poprzednio: tylko to, co przejdzie przez menedżera tego runnera, należy do transakcji. Blok try kończy się commitTransaction(), a catch wywołuje rollbackTransaction() i rzuca wyjątek dalej, żeby warstwa wyżej wiedziała, że transfer się nie udał.Najważniejsza jest ostatnia część.
release() musi stać w finally, bo połączenie trzeba zwrócić do puli w każdym scenariuszu - i po sukcesie, i po błędzie. Pominięcie tego nie zepsuje pojedynczego transferu; skutek pojawi się później, gdy pula wyczerpie się z wolnych połączeń i cała aplikacja stanie. To najczęstszy błąd przy ręcznych transakcjach.Zwróć też uwagę, czego
release() nie robi: nie zatwierdza ani nie cofa niczego. Jeśli zwolnisz runnera bez commitu, transakcja zostanie wycofana - zwolnienie połączenia to nie zapisanie zmian.Została nam litera I z ACID. Gdy dwie transakcje działają równocześnie, baza musi zdecydować, ile jedna widzi z niedokończonej pracy drugiej. Poziom tego odgrodzenia podajesz jako argument:
1await queryRunner.startTransaction('SERIALIZABLE');Domyślny poziom to zwykle
'READ COMMITTED' - widzisz tylko zmiany już zatwierdzone przez innych. 'SERIALIZABLE' to poziom najostrzejszy: transakcje wykonują się tak, jakby stały w kolejce, jedna po drugiej. Daje najsilniejsze gwarancje, ale kosztem przepustowości - baza częściej każe którejś transakcji się wycofać i spróbować ponownie. Dlatego poziom podnosimy świadomie i tylko tam, gdzie naprawdę trzeba, na przykład przy operacjach na saldzie skarbca.Puchar Marka nie zginie już nigdy w połowie drogi:
dataSource.transaction(async manager => {...}) prowadzi ten cykl za Ciebie: wyjście bez wyjątku to COMMIT, wyjątek to ROLLBACK,createQueryRunner, connect, startTransaction, a potem commitTransaction w try, rollbackTransaction w catch i release w finally,manager transakcji - zwykłe repozytorium działa poza nią,W następnej lekcji poznasz seedery - kwatermistrzów, którzy wyposażą świeżą bazę w dane startowe. A na razie zapamiętaj: transakcja to jeden rozkaz złożony z wielu ruchów - baza wykona go w całości albo udając, że nigdy go nie usłyszała, nie zrobi nic.