Раздел 27 · Gleam на практике

Process Manager и Saga: charge-on-checkout с компенсацией

senior~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Process Manager и Saga: charge-on-checkout с компенсацией

Первая ката релиза R4. До сих пор всё жило в одном контексте Reservations. Сегодня выходим за границу: выезд гостя во Front Desk должен выставить счёт в Billing. Это другой контекст, другой Decider, и между ними нужен посредник, который ведёт сценарий и умеет откатить, если что-то пошло не так.

Сцена · кто свяжет два контекста

В уроке 23 мы развели Customer на два контекста и сказали: контексты общаются через явный контракт, не через общий мутабельный объект. В уроке 30 write-сторона научилась порождать события. Теперь вопрос: гость выехал (GuestCheckedOut в Reservations), нужно выставить и закрыть счёт (Folio в Billing). Кто это организует?

Не сам агрегат брони: он не знает про Billing и не должен. Не Folio: он не слушает чужие события. Нужен третий участник, который слушает событие одного контекста и шлёт команду другому.

Process Manager это такой координатор. У него есть маленькое состояние (где мы в сценарии) и логика: увидел событие X, отправь команду Y. Когда сценарий длинный и при сбое в середине нужно не откатить транзакцию, а компенсировать уже сделанное, процесс-менеджер называют Saga.

Карта урока · что заберёшь через 3 часа

  • Поймёшь, чем процесс-менеджер отличается от агрегата и почему он не владеет данными.
  • Соберёшь Folio Decider: у Billing свой Decider по форме урока 28.
  • Соберёшь сагу charge-on-checkout: реагирует на выезд, шлёт IssueFolio, при сбое компенсирует RevertCheckout.
  • Поймёшь, почему сага Decider-образна, но её состояние сворачивается по триггерам, а не по своим событиям.

Концепт · у Billing свой Decider

Сначала второй контекст. Billing держит счёт (folio) и управляет им своим Decider, по той же форме, что Reservations:

// examples/ddd-hotel/gleam/src/contexts/billing/folio.gleam

pub type FolioCommand {
  IssueFolio(ref: String, amount: Amount)
  SettleFolio(ref: String)
}

pub type FolioEvent {
  FolioIssued(ref: String, amount: Amount, occurred_at: Int)
  FolioSettled(ref: String, occurred_at: Int)
}

pub type Folio {
  NoFolio
  Issued(ref: String, amount: Amount)
  Settled(ref: String, amount: Amount)
}

Заметь две вещи про границу. Сумма это Int в копейках, а не Money с фантомной валютой из урока 18: тащить чужой тип через границу контекста не надо, Billing держит свою арифметику. И корреляция с бронью идёт через ref: String, а не через reservation.ReservationId: id пересекает границу как обычное значение, ровно как в мосте урока 23. Контексты не делят типы, они обмениваются примитивами и фактами.

folio_decider(now) собирается ровно как reservation_decider: decide отвергает невозможное (двойной счёт, нулевая сумма), evolve применяет факт. Это подтверждает урок 28: Decider это форма, а не разовый трюк, второй контекст переиспользует её один в один.

Концепт · сага это Decider, но вход и выход разнесены

Теперь координатор. Сага charge-on-checkout это:

  • состояние (где в сценарии): Idle, AwaitingFolio, Completed, Reverted.
  • триггеры (что она слышит): CheckoutObserved, FolioIssued, FolioFailed.
  • реакции (что она шлёт): IssueFolio, RevertCheckout.

По форме это Decider: есть initial, есть decide (на триггер вернуть реакции), есть evolve (триггер двигает состояние). Но есть тонкое и важное отличие от агрегата. У агрегата decide и evolve работают с одним типом события: что decide породил, тем evolve и кормится. У процесс-менеджера вход и выход разные: evolve сворачивает состояние по триггерам (входящим сообщениям), а decide возвращает реакции (исходящие команды в чужой контекст).

// examples/ddd-hotel/gleam/src/contexts/charge_on_checkout.gleam

pub type Trigger {
  CheckoutObserved(ref: String, amount: Int)
  FolioIssued(ref: String)
  FolioFailed(ref: String)
}

pub type Reaction {
  IssueFolio(ref: String, amount: Int)
  RevertCheckout(ref: String)
}

pub type Saga {
  Idle
  AwaitingFolio(ref: String, amount: Int)
  Completed(ref: String)
  Reverted(ref: String)
}

Из-за этого расхождения типов мы не переиспользуем общую запись Decider (там decide и evolve делят тип события), а пишем decide/evolve явной парой. Это не отступление от паттерна, это его честная вариация: процесс-менеджер это Decider, у которого память о входах и список выходов разнесены.

Задача · сигнатуры

Собери charge_on_checkout.gleam:

pub fn initial() -> Saga
pub fn decide(state: Saga, trigger: Trigger) -> List(Reaction)
pub fn evolve(state: Saga, trigger: Trigger) -> Saga

Контракт сценария (это и есть дорожка обмена между контекстами):

  • Idle слышит CheckoutObserved(ref, amount) это решение [IssueFolio(ref, amount)], состояние едет в AwaitingFolio.
  • AwaitingFolio слышит FolioIssued это реакций нет ([]), состояние Completed. Счёт выставлен, сценарий закрыт.
  • AwaitingFolio слышит FolioFailed это компенсация [RevertCheckout(ref)], состояние Reverted.
  • Любой неожиданный триггер игнорируется: реакций нет, состояние не меняется. Сага идемпотентна к шуму.

decide без ошибок (возвращает просто List(Reaction)): саге нечего отвергать, она реагирует или молчит.

Подсказки

  • decide и evolve матчат пару case state, trigger. Это самый читаемый способ выразить “в таком состоянии на такой триггер”.
  • Не клади бизнес-инвариант в сагу. Можно ли выставить счёт, решает Folio Decider, не сага. Сага только организует: “выехал, запроси счёт; не вышло, откати”.
  • Компенсация это не откат БД. RevertCheckout это новая команда (“верни гостя в заселённого”), которая пойдёт обратно во Front Desk. Распределённую транзакцию не откатывают, её компенсируют встречным действием.
  • Реакции и триггеры это разные типы. Реакция IssueFolio уходит в Billing и там превращается в команду Folio. Ответ Billing (FolioIssued/FolioFailed) возвращается саге уже как триггер. Кто-то снаружи (драйвер) крутит эту петлю.

Разбор · decide и evolve саги

pub fn decide(state: Saga, trigger: Trigger) -> List(Reaction) {
  case state, trigger {
    Idle, CheckoutObserved(ref, amount) -> [IssueFolio(ref, amount)]
    AwaitingFolio(_, _), FolioFailed(ref) -> [RevertCheckout(ref)]
    AwaitingFolio(_, _), FolioIssued(_) -> []
    _, _ -> []
  }
}

pub fn evolve(state: Saga, trigger: Trigger) -> Saga {
  case state, trigger {
    Idle, CheckoutObserved(ref, amount) -> AwaitingFolio(ref, amount)
    AwaitingFolio(ref, _), FolioIssued(_) -> Completed(ref)
    AwaitingFolio(ref, _), FolioFailed(_) -> Reverted(ref)
    _, _ -> state
  }
}

Прочитай как фильм: Idle плюс выезд равно “запроси счёт и жди”; AwaitingFolio плюс успех равно “закройся”; AwaitingFolio плюс провал равно “компенсируй”. Всё остальное равно “промолчи”. Это полный сценарий на десяти строках.

Замыкаем петлю · сага и Folio вместе

Сага сама ничего не исполняет, она только говорит, что сделать. Кто-то должен взять реакцию, выполнить её через Billing и вернуть саге ответ-триггер. В тесте это видно явно:

// examples/ddd-hotel/gleam/test/charge_on_checkout_test.gleam

fn run_reaction(reaction: charge_on_checkout.Reaction) -> charge_on_checkout.Trigger {
  let d = folio.folio_decider(now)
  case reaction {
    IssueFolio(ref, amount) ->
      case d.decide(folio.NoFolio, folio.IssueFolio(ref, amount)) {
        Ok(_) -> FolioIssued(ref)
        Error(_) -> FolioFailed(ref)
      }
    RevertCheckout(ref) -> FolioFailed(ref)
  }
}

pub fn happy_path_loop_reaches_completed_test() {
  let s0 = charge_on_checkout.initial()
  let trigger0 = CheckoutObserved("res_saga0002", 20_000)
  let assert [reaction] = charge_on_checkout.decide(s0, trigger0)
  let s1 = charge_on_checkout.evolve(s0, trigger0)

  let answer = run_reaction(reaction)
  let s2 = charge_on_checkout.evolve(s1, answer)

  s2 |> should.equal(Completed("res_saga0002"))
}

run_reaction это микро-драйвер: берёт реакцию саги, исполняет её через настоящий Folio Decider, возвращает триггер-ответ. Штатный сценарий доходит до Completed. А если сумма нулевая, Folio Decider отвергнет IssueFolio, драйвер вернёт FolioFailed, и сага выпустит RevertCheckout и уедет в Reverted. Компенсация сработала, и всё это чистыми функциями, без единого мьютекса.

Критика · что не покрыто

  • Нет таймаута. Если Billing промолчал, сага вечно висит в AwaitingFolio. Реальная сага ставит таймер и компенсирует по таймауту (ДЗ).
  • Драйвер игрушечный. run_reaction синхронен и работает на свежем NoFolio. Настоящий драйвер слушает read_all event store урока 29, держит Folio-стримы по ref и крутит петлю в акторе (ДЗ).
  • Сага не персистится. Её состояние живёт в памяти. После рестарта забыто, кто ждёт счёт. Зрелая сага сама event-sourced: её состояние это свёртка её собственного стрима (ДЗ, уровень L).
  • Идемпотентность намечена, но не доказана. Мы игнорируем неожиданные триггеры, но повторный CheckoutObserved для уже идущей саги стоит проверить тестом (ДЗ).

Takeaway

Одна фраза:

Process Manager слушает события одного контекста и шлёт команды другому, держа маленькое состояние сценария; сага добавляет компенсацию вместо отката. По форме это Decider, но его состояние сворачивается по входящим триггерам, а decide возвращает исходящие реакции.

ДЗ

Дальше

Следующая ката · 32. Aggregateless ES. Сегодня граница агрегата нам помогала. В следующем уроке возьмём случай, где она мешает (перевод денег между счетами), и посмотрим на единый стрим без агрегатных границ: Fact-функции вместо одного состояния.

Параллельно полезно перечитать:

  • 23. Bounded Contexts, про границы и мост. Сага это динамический мост: она переводит события одного контекста в команды другого во времени.
  • 28. Decider, про форму decide/evolve. Сага показала её вариацию: вход и выход разных типов.