Process Manager и Saga: charge-on-checkout с компенсацией
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
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_allevent store урока 29, держит Folio-стримы поrefи крутит петлю в акторе (ДЗ). - Сага не персистится. Её состояние живёт в памяти. После рестарта забыто, кто ждёт счёт. Зрелая сага сама event-sourced: её состояние это свёртка её собственного стрима (ДЗ, уровень L).
- Идемпотентность намечена, но не доказана. Мы игнорируем неожиданные триггеры, но повторный
CheckoutObservedдля уже идущей саги стоит проверить тестом (ДЗ).
Takeaway
Одна фраза:
Process Manager слушает события одного контекста и шлёт команды другому, держа маленькое состояние сценария; сага добавляет компенсацию вместо отката. По форме это Decider, но его состояние сворачивается по входящим триггерам, а
decideвозвращает исходящие реакции.
ДЗ
Дальше
Следующая ката · 32. Aggregateless ES. Сегодня граница агрегата нам помогала. В следующем уроке возьмём случай, где она мешает (перевод денег между счетами), и посмотрим на единый стрим без агрегатных границ: Fact-функции вместо одного состояния.
Параллельно полезно перечитать:
- 23. Bounded Contexts, про границы и мост. Сага это динамический мост: она переводит события одного контекста в команды другого во времени.
- 28. Decider, про форму
decide/evolve. Сага показала её вариацию: вход и выход разных типов.