OxL Справочник OxL
РусскийEnglish

Саги

Транзакции через границы сервисов невозможны, поэтому долгий процесс записывают сагой (§11.3): шаги идут по очереди (->), у шага может быть undo — компенсация, которая запускается, если позднее шаг не удался. Компенсации выполняются в обратном порядке, после чего сага возвращает исходную ошибку. Состояние саги журналируется, так что при падении узла её подхватывает другой; шаги и компенсации должны быть идемпотентными (@idempotent, §11.4).

oxl✓ проверяется в CI
@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), и поздний эффект становится недействительным. Компенсация первого шага отменяет и последующие:

oxl
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.

См. также

OxL 1.0.0-beta · Версия документации 1.0.0-beta© OxwaldLink · сгенерировано из Стандарта, исходников std и руководства