Jak obsłużyć unikalność w Event Sourcing?
Cześć!
Na początku mały bonus. Wspominałem już o konferencji DynamIT w sierpniu w Krakowie. Może to być pierwsza pocovidowa konferencja na żywo. Wysłałem swoje zgłoszenie jako prelegenta, więc możliwe, że też tam będę.
Dostałem od organizatorów dla czytelników 15% kod zniżkowy: Oskar&dynamIT
. Zerknij, fajnie by było wreszcie spotkać się na żywo. Webinary są fajne, ale szczerze po ostatniej serii mam już trochę ich dosyć. Spotkania na żywo FTW!
Jednym z pierwszych pytań, które pojawiają się, gdy skończy się lekturę artykułów o Event Sourcing, a zacznie się pisanie kodu, jest: “Jak obsłużyć unikalność?”. Na przykład unikalna nazwa użytkownika albo numer faktury.
Unikalność to w ogóle ciekawa sprawa. Z mojego prawie czternastoletniego doświadczenia wynika, że wymaganie unikalności to często mit. Bardzo często, jak dostajemy wymaganie unikalności, oznacza ono coś innego niż samą unikalnością. Najczęściej biznes próbuje nam przynieść rozwiązanie, a nie problem. Zawsze warto podrążyć, bo często okazuje się, że problem leży gdzieś indziej i trzeba podejść do problemu inaczej.
Często jednak nie mamy specjalnie wyboru lub po prostu unikalność e-maila nie jest czymś, o co warto kruszyć kopię. Co wtedy zrobić, gdy faktycznie “musimy musimy” to zrobić?
Klasycznie możemy użyć klucza na bazie. W Event Sourcing też możemy go użyć, o ile używamy implementacji na bazie relacyjnej. Na przykład Marten umożliwia następujący trick:
- tworzymy automatyczny Snapshot, który będzie miał aktualny stan naszego agregatu (strumienia). Taki Snapshot będzie zapisany jako pojedynczy rekord w bazie danych.
- dla Snapshot możemy zdefiniować unikalny klucz na polach. Marten przechowuje dane snapshota w postaci dokumentowej: kolumny o typie JSON. Postgres (którego Marten jest nakładką) pozwala na zdefiniowanie kluczy również na polu JSON.
Możemy Snapshot oznaczyć jako “Inline”, dzięki czemu będzie aktualizowany w tej samej transakcji co dodawane zdarzenie. Zatem jeśli snapshot zostanie dodany/uaktualniony to bazodanowy klucz zadba nam o to, żeby unikalność była zapewniona.
Uczciwie powiedzmy sobie się jednak, że to jest pragmatyczny trick, nie jest to rozwiązanie książkowe. Wiele rozwiązań Event Sourcing nie daje takich możliwości. W większości mamy podobne ograniczenia jak w bazach klucz/wartość.
Klucze i indeksy są fajne, ale ograniczają wydajność, mogą powodować deadlocki itd. Dlatego, jeśli chcemy wycisnąć maksa z logu zdarzeń i z jego charakterystyki “append only” to warto jednak przemyśleć inne rozwiązania.
Wszystkie event store’y, które znam, zapewniają unikalność klucza na poziomie identyfikatora strumienia zdarzeń. Dla przypomnienia, strumień jest to uporządkowana kolekcja zdarzeń, które mają wspólny identyfikator. Czyli np. zdarzenia dla danego użytkownika. Identyfikatorem może być wtedy np. e-mail tego użytkownika.
Skoro identyfikator strumienia jest unikalny, to formatując go jako: ‘user_{email}’ możemy łatwo zapewnić unikalność użytkownika po mailu.
OK, tylko czy na pewno łatwo? Co, gdy użytkownik zmieni swój email? Albo jak dojdzie nam kolejne pole do unikalności? Lub ekhm, ekhm, e-mail to dana GDPR?
Pierwszym usprawnieniem jest dodanie dobrej funkcji hashującej. Pozwoli na modyfikację o nowe pola oraz anonimizację, ale ciągle nie zapewni nam zmiany maila.
Z pomocą przychodzi nam wzorzec “Rezerwacja”. W nim pierwszą operacją, którą robimy przy wykonaniu operacji biznesowej, jest wystąpienie o rezerwację zasobu: np. unikalnej wartości e-mail. Występujemy to poprzed dodanie nowego wpisu w jakimś storage. W zasadzie jest obojętne, jaki wybierzemy, ważne, żeby wspierał unikalność klucza. Możemy np. użyć osobnego strumienia. Rezerwacja może być synchroniczna lub asynchroniczna (gdy np. wymaga więcej logiki biznesowej niż tylko dodanie wpisu). Dopiero gdy dostaniemy zwrotkę, że rezerwacja się powiodła, możemy wykonać właściwą logikę biznesową i zapisać rekord.
Gdy użytkownik zmienił email, sutuacja wygląda podobnie, tylko w drugą stronę. Wykonujemy logikę biznesową, a na koniec wysyłamy żądanie o zwolnienie zarezerwowanego zasobu. Można to porównać do starego dobrego wzorca współbieżności: Semafor.
Nie wydaje to się wielce skomplikowane, ale może się takie zrobić. Co zrobić, gdy zarezerwujemy zasób, ale logika biznesowa się wywali, albo po prostu dane się nie zapiszą? Co, gdy zmienimy email, ale żądanie zwolnienia zasobu się niepowiedzie? Jeśli nasz storage nie wspiera transakcyjności (a duża część baz klucz wartość i event store’ów tego nie wspiera) to mamy problem.
Tutaj dochodzimy do problemów rozproszonych systemów. Pisałem o tym więcej w swoich wpisach o wzorcach Outbox (https://event-driven.io/pl/outbox_inbox_patterns_and_delivery_guarantees_explained/) oraz Saga (https://event-driven.io/pl/saga_process_manager_distributed_transactions/). Tak jak zawsze, wszystko zależy od tego, jak oceniamy nasze ryzyko i jakie są jego konsekwencje. Najbezpieczniej założyć, że wszystko może pójść nie tak i mieć w zanadrzu akcję kompensującą. Na przykład timer, który jeśli nie dostanie zdarzenia w ciągu 15m potwierdzającego sukces logiki biznesowej, to anuluje rezerwację. Ewentualnie wystawienie administracyjnej metody, na ręczne zwolnienie zasobu. Wiadomo, takie rzeczy się będą zdarzać rzadko, ale jak się zdarzą, to nie chcielibyśmy raczej musieć wdrażać nowej wersji oprogramowania, żeby skorygować dane jakąś migracją.
Jak zwykle, sytuacje są proste, dopóki nie przestaną nimi być. Warto zawsze upewnić się, czy naprawdę musimy zachować unikalność. Jeśli nie to przeanalizować nasze opcje, ryzyka i podjąć pragmatyczną decyzję dopasowaną do naszego problemu biznesowo-technicznego.
Co Ty o tym sądzisz?
Pozdrawiam
Oskar
p.s. zachęcam do lektury mojego ostatniego artykułu o tym “Kiedy Agile to za mało”: https://event-driven.io/en/when_agile_is_not_enough/.
oraz nowego Architecture Weekly: https://github.com/oskardudycz/ArchitectureWeekly#7th-june-2021.