Саги
Транзакции через границы сервисов невозможны, поэтому долгий процесс записывают сагой (§11.3): шаги идут
по очереди (->), у шага может быть undo — компенсация, которая запускается, если позднее шаг не удался. Компенсации выполняются
в обратном порядке, после чего сага возвращает исходную ошибку. Состояние саги журналируется, так что при падении узла её
подхватывает другой; шаги и компенсации должны быть идемпотентными (@idempotent, §11.4).
@error data Declined { why: str }
service Ledger {
@public @idempotent rpc openAcct(id: u64) -> u64
@public @idempotent rpc closeAcct(id: u64) -> ()
@public @idempotent rpc charge(id: u64) -> u64 | Declined
}
impl Ledger {
rpc openAcct(id) {
print("open {id}")
id
}
rpc closeAcct(id) { print("close {id}") }
rpc charge(id) {
if id > 1 { fail Declined{ why: "limit" } }
id
}
}
saga flow(a: u64, b: u64) -> u64 {
Ledger.openAcct(a) undo x => Ledger.closeAcct(x)
-> Ledger.openAcct(b) undo y => Ledger.closeAcct(y)
-> Ledger.charge(b)
}
fn main() io, net, time, db {
match flow(1, 2) {
u64 v => print("done {v}")
Declined d => print("declined {d.why}")
CompensationFailed c => print("stuck")
NetError e => print("net error")
}
}open 1 open 2 close 2 close 1 declined limit
Если сама компенсация не удаётся, сага получает состояние «застряла» и сообщает CompensationFailed; такие саги видны в
инспекторе (oxl inspect). Полный пример с внешними складом и платежами — оформление заказа в shop.
Шаги с неизвестным исходом#
Шаг, исход которого к моменту компенсации неизвестен (истёк дедлайн вызывающего, или узел, выполнявший сагу, потерял аренду при плановом перезапуске и сагу подхватил другой),
считается неудавшимся, а неудавшийся шаг не компенсируется: его undo x => … требует результата x, которого нет. Но вызов может быть ещё в пути, и его эффект способен
появиться после того, как компенсации предыдущих шагов уже выполнились: списание, которое никто не вернёт. Поэтому Стандарт требует (§11.3), чтобы вызываемый сервис
умел отменить или обезвредить такой поздний эффект по ключу идемпотентности шага (ctx.idempotencyKey).
Приём (фейковые сервисы нагрузки Jepsen, tests/jepsen/app.oxl): каждый эффект записывается под своим ключом, а отмена, пришедшая раньше, оставляет под тем же ключом метку отмены
(tombstone), и поздний эффект становится недействительным. Компенсация первого шага отменяет и последующие:
actor Bank sharded by id: u64 {
var seen: Map<str, i64> = {:}
msg charge(order: str, amount: i64) -> str {
let prev = seen.get("{order}#c") ?? 0
if prev < 0 { return "declined" } // отменено до прихода: метка отмены
if prev > 0 { return "ok" } // повторный вызов: ответ из записи
balance -= amount
seen.insert("{order}#c", amount)
"ok"
}
msg refund(order: str) -> str { // отменяет списание или оставляет метку для позднего
let prev = seen.get("{order}#c") ?? 0
if prev > 0 { balance += prev }
seen.insert("{order}#c", -1)
"ok"
}
}
saga pay(order: str, acct: u64, item: u64, amount: i64) -> str {
Ledger.reserve(order, item) undo r => Ledger.cancel(order, item, acct) // cancel = release + refund (метка отмены)
-> Ledger.charge(order, acct, amount)
}Ledger.cancel снимает резерв и вызывает Bank.refund для заказа: если списание применилось, оно отменяется, иначе метка отмены делает запоздавший charge недействительным.
Без этого сага, которую компенсируют, пока charge в пути, может закончиться состоянием «списано, резерв снят, возврата нет». То же верно для любого шага ваших саг: дайте сервису каждого
шага с побочными эффектами отмену, работающую по ключу, и вызывайте её из undo более раннего шага (или из отдельного rpc отмены). Форма undo, которая выполняется для шагов
с неизвестным исходом по их ключу, запланирована после 1.0.