Dynamic Context Boundary

Dynamic Consistency Boundary

Jak obsłużyć alokację wielu zasobów bez zakleszczeń bazy? Sprawdź, czym jest Dynamic Consistency Boundary i dlaczego tradycyjne agregaty czasami wysiadają.

Odkrywanie bounded contexts i problem z tym związany

Modelowanie na żółtych karteczkach podczas sesji Event Storming zazwyczaj kończy się spektakularnym sukcesem. Identyfikujemy granice spójności, grupujemy zachowania i z poczuciem dobrze wykonanej pracy schodzimy do najniższego poziomu modelowania, a gdzieś na końcu definiujemy mityczne agregaty.

W teorii Domain-Driven Design agregat to forteca strzegąca niezmienników (aka wymagań i reguł) biznesowych. Gwarantuje, że stan systemu po zakończeniu transakcji będzie poprawny. Ale zderzenie z rzeczywistością następuje w momencie, gdy reguły biznesowe przestają układać się w proste drzewa zależności, a zaczynają tworzyć wielowymiarową siatkę.

Wtedy agregaty zaczynają puchnąć, bądź sama logika domenowa zaczyna cieknąć do warstwy aplikacyjnej (serwisów, handlerów), albo co gorsza pojawia się coupling między różnymi kontekstami. A miało być tak pięknie :).

Weźmy na tapet archetyp dostępności zasobu (rezerwacje, alokacje i inne sytuacje)

Kiedy projektujemy systemy rezerwacyjne, od salonów fryzjerskich po wynajem flot samochodowych, naturalnym kierunkiem jest sięgnięcie po klasyczne wzorce Domain-Driven Design. Dzielimy czas na precyzyjne okienka, tworzymy pule dostępności i budujemy eleganckie, polimorficzne kompozyty. W świecie idealnym pojedyncza transakcja zajmuje slot dla fotela fryzjerskiego, pracownika i niezbędnych narzędzi. Kod jest silnie typowany, zasady biznesowe są hermetycznie zamknięte w modelach, a my z dumą patrzymy na architekturę, która wydaje się gotowa na wszystko.

Spójrzmy na typową, poprawną obiektowo implementację takiego złożonego zasobu. Budujemy klasę, która przyjmuje żądanie rezerwacji, weryfikuje dostępność wszystkich części składowych i próbuje zablokować je jedna po drugiej.

class ResourceAvailability {
    constructor(
        public readonly resourceId: ResourceId,
        private readonly componentEntries: ReadonlyArray<{ 
            componentId: ResourceId; 
            availability: ResourceAvailability 
        }>,
        private readonly compositeBlockades: Map<BlockadeId, ReadonlyMap<ResourceId, BlockadeId>>
    ) {}

    lock(request: CompositeLockRequest): Result<string, BlockadeId> {
        const lockedComponents = new Map<ResourceId, BlockadeId>();

        for (const { componentId, availability } of this.componentEntries) {
            const componentRequest = request.getRequestForComponent(componentId);
            const lockResult = availability.lock(componentRequest);
            
            if (!lockResult.ok) {
                for (const [lockedId, blockadeId] of lockedComponents) {
                    const comp = this.getComponentAvailability(lockedId);
                    comp.unlock(UnlockRequest.of(request.owner, blockadeId));
                }
                return Result.err(`Failed to lock components: ${componentId}: ${lockResult.error}`);
            }
            
            lockedComponents.set(componentId, lockResult.value);
        }

        const compositeBlockadeId = BlockadeId.composite([...lockedComponents.values()]);
        this.compositeBlockades.set(compositeBlockadeId, lockedComponents);
        
        return Result.ok(compositeBlockadeId);
    }

    // ...
}

Można to zrobić oczywiście jeszcze ładniej i dokładniej z punktu widzenia onanistów DDD, natomiast chodzi tutaj o ideę. Bo pęknięcia na tym pięknym architektonicznym obrazie pojawiają się w momencie zderzenia z rozproszoną rzeczywistością.

Powyższy kod skrywa w sobie mroczny sekret, widzisz go już na tym etapie?

Jeśli uda nam się zarezerwować pierwsze dwa zasoby, ale trzeci zgłosi konflikt, nasz kod musi wykonać dramatyczny krok wstecz. Pewnie uruchomimy pętlę, która przejdzie przez zablokowane wcześniej elementy i ręcznie wywoła na nich operację odblokowania, próbując przywrócić system do stanu początkowego. Oczywiście są też transakcje, ale legenda głosi, że te rozproszone kiedyś zadziałają…

Podsumujmy problem

I teraz, raz jeszcze, na chłopski rozum, o co Ci gościu chodzi. Zaimplementowaliśmy synchronicznego menedżera procesów ukrytego w pamięci pojedynczego wątku. Dopóki nasza aplikacja działa jako jedna instancja w środowisku deweloperskim, wszystko przebiega bezbłędnie. Jednak na produkcji, gdzie horyzontalnie skalujemy usługi do dziesięciu węzłów, ten mechanizm staje się generatorem katastrof.

Dwie równoległe transakcje zaczynają zajmować przecinające się zasoby w odwrotnej kolejności. Baza danych rzuca wyjątkami optymistycznego blokowania, a nasz system spędza więcej czasu na gorączkowym odkręcaniu własnych blokad kompensacyjnych niż na faktycznym procesowaniu biznesu. Przy masowym obciążeniu ten elegancki, klasyczny model alokacji zaczyna dławić przepustowość.

Od razu też zaznaczę, że problem nie polega na tym, że klasyczny model agregatów z definicji źle skaluje się horyzontalnie. Problem pojawia się wtedy, kiedy jedna operacja wymaga jednoczesnej koordynacji wielu niezależnych agregatów i próbujesz uzyskać silną spójność między nimi.

No to jak żyć? Wszyscy guru powiedzą, że to jest źle zamodelowane i co… mają rację, można było to zrobić lepiej. Natomiast co, jeśli powiem Ci, że istnieje łatwiejsze rozwiązanie? Że nie musisz być potentatem mitycznego cechu konsultantów? Jeśli mam Twoją uwagę, to zapraszam do dalszej lektury.

Dynamic Consistency Boundary

Tu właśnie na scenę wkracza Dynamic Consistency Boundary. Wzorzec ten odrzuca koncepcję fizycznego ładowania predefiniowanych agregatów i iteracyjnego blokowania ich tabel w bazie danych. Zamiast tego, w architekturze opartej na Event Sourcingu, definiujemy granicę spójności w sposób całkowicie ulotny, wyłącznie na czas przetwarzania jednej konkretnej komendy.

Zamiast pytać system o stan kilku różnych zasobów i kompensować błędy w pamięci, wysyłamy do wyspecjalizowanego magazynu zdarzeń prośbę o wycięcie precyzyjnego kontekstu. Używamy do tego tagów (zaraz rozjaśni się na przykładzie). W naszym przykładzie salonu fryzjerskiego interesują nas wyłącznie historyczne fakty opisane identyfikatorem konkretnego pracownika oraz konkretnego fotela. Na podstawie tego wąskiego wycinka historii budujemy błyskawiczny, jednorazowy model decyzyjny.

Kluczowa zmiana paradygmatu zachodzi w momencie zapisu. Nie używamy pętli wycofujących transakcje ani wieloetapowych blokad. Przekazujemy nowe zdarzenie rezerwacji do Event Store z jednym twardym warunkiem. Magazyn danych ma prawo dopisać ten fakt tylko wtedy, gdy od momentu odczytu w żadnym ze strumieni oznaczonych naszymi tagami nie pojawiło się żadne nowe zdarzenie. I tyle! Jeśli stan uległ modyfikacji, baza atomowo odrzuca operację. Cały ciężar wykrywania konfliktów współbieżności zostaje przeniesiony z kodu aplikacyjnego do mechanizmu warunkowego zapisu Event Store.

Implementacja transakcji bez kompensacji z Dynamic Consistency Boundary

Serwis aplikacyjny zostaje w tym układzie sprowadzony do roli eleganckiego orkiestratora (i tutaj nic się nie zmienia pod względem podejścia z agregatami), ale to w jego implementacji kryje się cała magia dynamicznego definiowania granic (dynamic consistency boundary). Zwróćmy uwagę, jak konstruowane jest zapytanie do bazy danych. Zamiast używać klasycznego zapytania, które musiałoby wykonać skomplikowane łączenia tabel zasobów, użytkowników i kalendarzy, koordynator tworzy jednowymiarową tablicę tagów.

To właśnie w tym miejscu następuje fizyczne wyrysowanie granic spójności. Chcąc zrealizować operację, żądamy od Event Store wydobycia wszystkich faktów z przeszłości, które są powiązane z którymkolwiek ze wskazanych identyfikatorów, z osobą wynajmującego lub z konkretną datą kalendarzową.

class AllocateResource {
    constructor(private readonly eventStore: EventStore) {}

    async execute(
       resourceIds: ResourceId[], 
       ownerId: OwnerId, 
       slot: TimeSlot): 
    Promise<void> {        
        const tags = [
            ...resourceIds.map(id => `resource:${id}`),
            `owner:${ownerId}`,
            `date:${slot.getDateKey()}`
        ];
        
        const { events, expectedHeadPosition } = 
                 await this.eventStore.loadByQuery({ matchTags: tags });

        const timeline = ResourceTimeline.recreate(events, ownerId);

        const result = timeline.allocate(slot, resourceIds);

        if (!result.ok) return result;

        const resourceAllocated = result.value.

        await this.eventStore.appendWithCondition([resourceAllocated], expectedHeadPosition);

        return Result.ok();
    }
}

Natomiast ResourceTimeline to będzie nasz strażnik niezmienników domenowych, agregat? No, ale agregat na podstawie dynamicznie dobranego zestawu eventów, to nie jest to agregat w klasycznym sensie. Słowo „agregat” można więc tutaj użyć w sensie obiektu, który pilnuje niezmienników podczas konkretnej operacji. Natomiast zakres jego kontekstu został zdefiniowany przez tagi. I jeśli w przyszłości dojdzie nowe wymaganie biznesowe, np. że rezerwacji można dokonać tylko jeśli ktoś kupił pakiet premium, to po prostu rozszerzamy granicę naszego kontekstu o kolejny tag. Bez cieknącej domeny do warstwy widoku, bez łamania granic, które sobie świadomie postawiliśmy.

class ResourceTimeline {
    private constructor(
        private readonly allocatedSlots: TimeSlot[],
        private readonly ownerDailyAllocationsCount: number,
        private readonly targetOwnerId: OwnerId
    ) {}

    static recreate(events: ReadonlyArray<DomainEvent>, targetOwnerId: string): ResourceTimeline {
        const slots: TimeSlot[] = [];
        let allocationsCount = 0;
        
        for (const event of events) {
            if (event.type === 'ResourceAllocated') {
                slots.push(new TimeSlot(new Date(event.payload.start), new Date(event.payload.end)));
                if (event.payload.ownerId === targetOwnerId) allocationsCount++;
            }
            if (event.type === 'AllocationReleased') {
                // ...
            }
        }
        
        return new ResourceTimeline(slots, allocationsCount, targetOwnerId);
    }

    public allocate(slot: TimeSlot, resourceIds: string[]): Result<string, DomainEvent> {
        if (this.allocatedSlots.some(s => s.overlapsWith(slot))) {
            // err
        }
        
        if (this.ownerDailyAllocationsCount >= 3) {
            // err
        }

        const resourceAllocated = {
            id: crypto.randomUUID(),
            type: 'ResourceAllocated',
            tags: [
                ...resourceIds.map(id => `resource:${id}`),
                `owner:${this.targetOwnerId}`,
                `date:${slot.getDateKey()}`
            ],
            payload: { 
                resourceIds, 
                ownerId: this.targetOwnerId, 
                start: slot.start, 
                end: slot.end
            }
        };

        return Result.ok(resourceAllocated);
    }
}

No i tak, jak wspomniałem wcześniej. Nieważne, czy blokujemy jednocześnie dwa czy dziesięć różnych zasobów, bo z perspektywy kodu po prostu rozszerzamy tablicę stringów w zapytaniu o kolejne pozycje. Nie musimy pobierać z bazy kilkunastu osobnych agregatów ani zarządzać ich cyklem życia. Odczytujemy jeden dedykowany zestaw faktów, odtwarzamy z niego naszą czystą oś czasu i delegujemy proces decyzyjny do domeny. That’s it!

Kiedy Dynamic Consistency Boundary się nie sprawdzi?

Fascynacja wyznaczaniem granic w locie łatwo prowadzi do overengineeringu. Trust me. Jeśli modelujesz naturalną hierarchię, taką jak np. dokument zamówienia i jego pozycje, klasyczny agregat to idealna forteca. Nie ma tu krzyżujących się reguł, więc wprowadzanie tagów to sztuka dla sztuki.

Drugim twardym blokerem jest technologia. Próba zaimplementowania DCB na standardowej relacyjnej bazie z użyciem Entity Framework czy Hibernate będzie mocno pod górkę. Wzorzec ten wymaga mechanizmu pozwalającego na atomowe sprawdzenie warunku obejmującego interesujący nas zestaw faktów. Event Store’y są do tego po prostu naturalnie dopasowane.

Warto też upewnić się, czy biznes naprawdę potrzebuje absolutnej spójności w tej samej milisekundzie. Jeśli system toleruje kilka sekund opóźnienia, klasyczny Process Manager operujący w ostatecznej spójności będzie znacznie łatwiejszy w utrzymaniu dla zespołu.

Na koniec pozostaje fizyka :D. W domenach generujących gigantyczny wolumen faktów, takich jak telemetria czy trading, każdorazowa rehydratacja stanu w locie natychmiast zadławi procesory. Tam, gdzie liczy się czysta przepustowość, trzeba będzie pomyśleć o snapshotach, projekcjach albo innych sposobach ograniczenia ilości odtwarzanej historii.

Podsumowując: czym jest Dynamic Consistency Boundary?

Dynamic Consistency Boundary (DCB) to wzorzec architektoniczny (często stosowany w architekturze sterowanej zdarzeniami / Event Sourcingu), który stanowi alternatywę dla tradycyjnych, sztywnych granic spójności znanych z Domain-Driven Design.

1. Ładujesz tylko te fakty (zdarzenia), które są faktycznie potrzebne do podjęcia konkretnej decyzji biznesowej (niezależnie od tego, z jakich strumieni pochodzą). Wykorzystuje się do tego tagi.

           const tags = [
                ...resourceIds.map(id => `resource:${id}`),
                `owner:${ownerId}`,
                `date:${slot.getDateKey()}`
            ];
            
            const { events, expectedHeadPosition } = 
                     await this.eventStore.loadByQuery({ matchTags: tags });

    2. Baza danych (Event Store) weryfikuje przy zapisie, czy od momentu odczytu danych do momentu zapisu nie pojawiły się w systemie nowe zdarzenia pasujące do tego dynamicznego zapytania (kryteria spójności / append conditions).

              await this.eventStore.appendWithCondition([resourceAllocated], query, expectedHeadPosition);
      Autor wpisu

      blog@orbisbit.com

      Komentarze

      Dodaj komentarz

      Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *

      Sprawdź również