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

V.5 Распределённый сервис «shop»

Сервис пользователей, заказов и корзин; оформление заказа сагой с внешними складом и платежами; загрузка фото пайплайном на GPU; события заказов через топик; живая статистика через топик и локальный broadcast.

Структура#

oxl
shop/
  oxl.toml  oxl.lock  oxl.schema.lock
  proto/inventory.proto  proto/payments.proto  proto/recs.proto
  src/
    main.oxl            // запуск фоновых задач
    cluster.oxl         // кластер и зависимости
    limits/limits.oxl   // константы
    config/config.oxl   // статический и динамический конфиг
    model/model.oxl     // общие типы
    users/users.oxl
    orders/orders.oxl
    cart/cart.oxl
    checkout/checkout.oxl
    photos/photos.oxl
    search/search.oxl   // поиск с A/B-экспериментом «search v2»
  tests/
  traffic/search.oxlrec // записанный трафик поиска (`oxl trace --record`)

limits/limits.oxl#

oxl
// Все таймауты, лимиты и размеры — здесь и только здесь (часть IV.1).
// Константы вычисляются при компиляции и подходят для аргументов атрибутов.

// Таймауты
pub const STD_TIMEOUT       = 3s      // по умолчанию для всех rpc (cluster.defaults)
pub const CHECKOUT_DEADLINE = 10s     // оформление заказа целиком, включая сагу
pub const DRAIN_TIMEOUT     = 30s     // сколько узел дорабатывает запросы при остановке
pub const SEARCH_P95_LIMIT  = 50ms    // guard A/B-эксперимента «search v2»: p95 поиска кандидата

// Лимиты и размеры
pub const PAGE_SIZE: u32    = 50      // размер страницы списков
pub const DEFAULT_MAX_CART: u32 = 100 // значение по умолчанию; меняется на лету через Features
pub const STATS_BUFFER: usize = 16    // буфер broadcast-хаба статистики на подписчика

// Время жизни и периоды
pub const CART_IDLE         = 30m     // через сколько неактивная корзина выгружается из памяти
pub const STATS_PERIOD      = 5s      // как часто пересчитывается статистика
pub const ORDER_EVENTS_RETENTION = 7d // сколько хранятся события заказов в топике

// Шардирование (менять только с миграцией данных)
pub const ORDER_SHARDS: u32   = 64
pub const STORAGE_SHARDS: u32 = 256

// Прочее
pub const THUMB_SIZES: [u32; 2] = [256, 1024]   // размеры превью фото, px
pub const AUTH_ISSUER       = "https://id.shop.example"
pub const PUBLIC_DOMAIN     = "shop.example"

config/config.oxl#

oxl
use self.limits { DEFAULT_MAX_CART }

// Статический конфиг: читается при старте, без перезапуска не меняется.
// Значения берутся из флагов, env (APP_DB_URL, APP_PAYMENTS_KEY…), файла или профиля.
pub config App {
  dbUrl: str = "builtin://shop"       // по умолчанию — встроенное хранилище кластера
  inventoryAddr: str                  // обязательное: без значения узел не стартует (код 78)
  paymentsAddr: str
  recsAddr: str = "localhost:9103"      // модель рекомендаций
  paymentsKey: secret<str>            // секрет: не попадает в логи и трассы
  profile dev {                       // значения для локальной разработки
    inventoryAddr = "localhost:9101"
    paymentsAddr  = "localhost:9102"
    paymentsKey   = "dev-key"
  }
}

// Динамический конфиг: хранится в кластере, меняется без перезапуска
// (oxl config set Features.maxCart 200).
pub dynamic Features {
  maxCart: u32 = DEFAULT_MAX_CART     // лимит позиций в корзине
  liveStats: Flag = Flag.on           // флаг можно включать по проценту пользователей
}

model/model.oxl#

oxl
// Идентификаторы — отдельные типы: UserId нельзя случайно передать вместо OrderId
pub(api) type UserId  = distinct u64
pub(api) type OrderId = distinct u64
pub(api) type PhotoId = distinct u64

// Уточнённые типы проверяются при декодировании запроса —
// обработчик никогда не увидит невалидный артикул или email
pub(api) type Sku     = str where matches(re"[A-Z0-9-]{3,32}")
pub(api) type Name    = str where len(1..=64)
pub(api) type Email   = str where matches(re".+@.+\..+")

// Роли пользователей; берутся из JWT-claim "roles" (см. cluster.oxl)
pub(api) enum AppRole { Customer, Support, Admin }

@derive(Clone, Eq, Show)
pub(api) data Item {
  sku: Sku
  title: str
  price: Money                        // цена за штуку
  qty: u32 where 1..=99               // количество, проверяется при создании
}

// Снимок корзины в момент оформления; передаётся в сагу
pub(api) data CartSnapshot { userId: UserId; items: List<Item>; total: Money }

// @open: в будущем могут появиться новые статусы, клиенты обязаны обрабатывать «_»
@open pub(api) enum Status { New = 1, Paid = 2, Shipped = 3, Cancelled = 4 }

// Ошибки предметной области. По сети они передаются клиенту как типизированные детали gRPC.
@error pub(api) data NotFound { what: str; id: u64 }
@error pub(api) data EmptyCart {}
@error pub(api) data CartFull { max: u32 }
@error pub(api) data EmailTaken {}
@error pub(api) data BadImage { reason: str }

users/users.oxl#

oxl
use super.model.*

// Таблица ORM: схема и миграции выводятся из этого объявления
@table("users") @index(.email)
pub(api) data User {
  @pk id: UserId                      // первичный ключ
  name: Name
  email: Email
  createdAt: Time = now()             // значение по умолчанию вычисляется при создании
}

// Контракт сервиса: из него генерируются сервер, клиенты и .proto
@v(1) pub(api) service Users {
  @readonly rpc get(id: UserId) -> User | NotFound            // readonly: безопасно повторять и кэшировать
  @idempotent rpc register(name: Name, email: Email) -> User | EmailTaken   // повтор с тем же ключом вернёт первый ответ
}

impl Users {
  dep db: Db                          // зависимость, её даёт provide в cluster.oxl

  // Типы параметров берутся из контракта сервиса
  rpc get(id) { db.find(User, id) ?? fail NotFound{ what: "user", id: id.raw } }

  rpc register(name, email) {
    // Запрос компилируется в SQL при сборке
    let taken = from u in db.User where u.email == email take 1
    if !taken.isEmpty() { fail EmailTaken{} }
    let u = User{ id: UserId(cluster.ids.next()), name, email }   // уникальный id в кластере
    db.insert(u)
    u
  }
}

// Доступ для внешних вызовов: по умолчанию всё запрещено
policy Users {
  allow get(id)  if caller.isUser(id) || caller.has(role: .Support)   // свой профиль или поддержка
  allow register if caller is .Anonymous                             // регистрация — без входа
}

orders/orders.oxl#

oxl
use super.model.*
use super.limits { PAGE_SIZE, ORDER_SHARDS, ORDER_EVENTS_RETENTION, STATS_BUFFER }

// @owner: пользователь видит только свои заказы, условие добавляется к запросам автоматически.
// @shard: таблица разбита по userId — все заказы пользователя лежат в одном шарде.
@table("orders") @owner(.userId, bypass: [.Support, .Admin])
@index(.userId, .createdAt) @shard(by: .userId, count: ORDER_SHARDS)
pub(api) data Order {
  @pk id: OrderId
  userId: UserId
  items: List<Item>                   // хранится сериализованным в одной колонке
  total: Money
  receiptId: str                      // id платежа во внешней системе
  status: Status = Status.New
  createdAt: Time = now()
}

pub(api) enum OrderEvent {
  Created(id: OrderId, userId: UserId)
  StatusChanged(id: OrderId, userId: UserId, status: Status)
}

// Топик: каждое событие получают все подписчики; порядок гарантирован внутри userId
@retain(ORDER_EVENTS_RETENTION)
pub(api) topic OrderEvents: OrderEvent sharded by .userId

@derive(Show) pub(api) data Stats { ordersToday: u64; revenueToday: Money }

// Топик статистики хранит только последнее значение
@retain(count: 1) pub topic StatsUpdates: Stats

// Локальный broadcast-хаб на каждом узле api: один поток из топика раздаётся всем клиентам узла.
// Медленный клиент теряет старые значения (DropOldest), новый сразу получает последнее (replay: 1).
pub static LIVE_STATS: Broadcast<Stats> = Broadcast<Stats>(buffer: STATS_BUFFER, slow: .DropOldest, replay: 1)

@v(1) pub(api) service Orders {
  @readonly rpc recent(page: u32) -> List<Order>
  @readonly rpc get(id: OrderId) -> Order | NotFound
  // internal: вызывается только сагой, внешним клиентам недоступен
  @idempotent @internal rpc create(snap: CartSnapshot, receiptId: str) -> Order
  // resumable: при обрыве клиент переподключится и продолжит с последнего offset
  @resumable rpc watch() -> stream<OrderEvent>
  rpc liveStats() -> stream<Shared<Stats>, Lagged>
}

impl Orders {
  dep db: Db

  rpc recent(page) {
    let uid = ctx.userId()?           // AccessDenied, если вызывает не пользователь
    // ключ шарда (userId) есть в условии — запрос идёт в один шард
    from o in db.Order where o.userId == uid
      order by o.createdAt desc skip page * PAGE_SIZE take PAGE_SIZE
  }

  rpc get(id) { db.find(Order, id) ?? fail NotFound{ what: "order", id: id.raw } }

  rpc create(snap, receiptId) {
    let o = Order{ id: OrderId(cluster.ids.next()), userId: snap.userId,
                   items: snap.items, total: snap.total, receiptId }
    // Транзакция БД: заказ и событие запишутся вместе;
    // событие уйдёт в топик только после коммита (outbox)
    atomic(db) {
      db.insert(o)
      emit OrderEvents(OrderEvent.Created(o.id, o.userId))
    }
    o
  }

  // Подписка только на события своего пользователя, начиная с новых
  rpc watch() { OrderEvents.subscribe(key: ctx.userId()?, from: .Latest) }

  // Каждый клиент — новый подписчик локального хаба
  rpc liveStats() { LIVE_STATS.subscribe() }
}

policy Orders {
  allow recent, get, watch if caller is .User           // любой вошедший пользователь (свои данные — через @owner)
  allow liveStats if caller.has(role: .Support) || caller.has(role: .Admin)
  // create не упомянут: внешним он недоступен, саге разрешён автоматически по графу вызовов
}

// Обычная функция модуля; db передаётся явно заимствованием
pub fn computeStats(db: &Db) -> Stats  db, time {
  let since = today().atStart().in(node.tz).time   // начало сегодняшнего дня по часовому поясу узла
  let todays = from o in db.Order where o.createdAt >= since
  Stats{ ordersToday: todays.len() as u64,
         revenueToday: todays.fold(Money.ZERO, (a, o) => a + o.total) }
}

cart/cart.oxl#

oxl
use super.model.*
use super.config { Features }
use super.limits { CART_IDLE }

// Шардированный актор: одна корзина на пользователя во всём кластере.
// Живёт на узле-владельце шарда userId; состояние (var) сохраняется в журнал после каждого сообщения.
@idle(CART_IDLE)                      // неактивная корзина выгружается из памяти, но не теряется
pub actor Cart sharded by userId: UserId {
  var items: List<Item> = []

  // Сообщения обрабатываются строго по одному — гонок за items нет
  msg add(item: Item) -> () | CartFull {
    let max = Features.get().maxCart.get()          // текущий лимит из динамического конфига
    if items.len() as u32 >= max { fail CartFull{ max } }
    items.push(item)
  }

  msg remove(sku: Sku) { items.retain(i => i.sku != sku) }

  // Копия содержимого для саги: сама корзина остаётся у актора
  msg snapshot() -> CartSnapshot {
    CartSnapshot{ userId, items: items.clone(),
                  total: items.fold(Money.ZERO, (a, i) => a + i.price * (i.qty as i64)) }
  }

  // Убирает только первые n позиций — те, что вошли в снимок заказа; добавленные после снимка остаются
  msg removeOrdered(n: usize) { items = items.iter().skip(n).collect() }
}

// Пользователь может работать только со своей корзиной; userId — ключ актора
policy Cart { allow * if caller.isUser(userId) }

checkout/checkout.oxl#

oxl
use super.model.*
use super.orders { Orders, Order }
use super.cart { Cart }
use super.limits { CHECKOUT_DEADLINE }
use super.photos { uploadPhoto as photoPipeline }   // переименование при импорте, чтобы не путать с rpc

// Внешние системы описаны в .proto; из них генерируются extern service и типы.
// reserve/release/charge/refund помечены в proto как идемпотентные — это нужно саге.
import proto "proto/inventory.proto"
import proto "proto/payments.proto"
import proto "proto/recs.proto"              // модель рекомендаций: rpc learn(user, items), не влияет на корзину и заказы

// Клиенты внешних сервисов; создаются через provide в cluster.oxl только на роли saga
dep inventory: Client<Inventory>
dep payments: Client<Payments>
dep recs: Client<Recs>

// Сага: шаги идут по очереди; если шаг упал, уже выполненные откатываются
// компенсациями (undo) в обратном порядке. Прогресс пишется в журнал,
// поэтому после падения узла сагу продолжит другой узел роли saga.
saga placeOrder(snap: CartSnapshot) -> Order {
  inventory.reserve(items: snap.items)                              // 1. резервируем товары
      undo r => inventory.release(r.reservationId)                 //    откат: снять резерв
  -> payments.charge(user: snap.userId.raw, amount: snap.total)   // 2. списываем деньги
      undo p => payments.refund(p.receiptId)                       //    откат: вернуть деньги
  -> Orders.create(snap, it.receiptId)                             // 3. создаём заказ; it — результат шага 2
}

// Публичный фасад для клиентов
@v(1) pub(api) service Shop {
  rpc addToCart(item: Item) -> () | CartFull
  rpc checkout() -> Order | EmptyCart | OutOfStock | Declined | CompensationFailed
  rpc uploadPhoto(img: bytes) -> PhotoId | BadImage
  rpc search(q: str) -> List<str>                              // вариант поиска выбирает A/B-эксперимент
}

impl Shop {
  // Cart(uid) — ссылка на актор; вызов уходит на узел, где живёт корзина
  rpc addToCart(item) { Cart(ctx.userId()?).add(item) }

  rpc checkout() {
    let uid = ctx.userId()?
    let res = await Cart(uid).snapshot()                        // берём снимок корзины
      -> s => if s.items.isEmpty() { EmptyCart{} } else { s }   // пустая корзина — ошибка значением, цепочка прерывается
      -> placeOrder(it)                                         // запускаем сагу
      within CHECKOUT_DEADLINE                                  // общий дедлайн оформления
    let order = res?
    // Очистку нельзя отправлять в фон: она могла бы прийти после следующего запроса пользователя
    // и стереть только что добавленное. Поэтому ждём; убираются лишь заказанные позиции.
    await Cart(uid).removeOrdered(order.items.len())
    // В фон выносится только то, что не влияет на состояние запроса: данные заказа для дообучения рекомендаций
    await order ~> recs.learn(user: uid.raw, items: it.items)
  }

  // Пайплайн отвечает сразу после сохранения фото, обработка продолжается в фоне
  rpc uploadPhoto(img) => photoPipeline(ctx.userId()?, img)

  rpc search(q) => searchCatalog(q)                            // search/search.oxl
}

policy Shop { allow * if caller is .User }   // все методы — для вошедших пользователей

OutOfStock и Declined — @error-типы из proto-файлов (oxl.error option). Шаг s => if … { EmptyCart{} } else { s } возвращает ошибку значением: тип шага CartSnapshot | EmptyCart, и EmptyCart прерывает цепочку (§8.4).

search/search.oxl#

oxl
use std.ab { ab }
use std.obs { metric }

data Entry { sku: str; title: str; tags: List<str> }

fn catalog() -> List<Entry> => [ … ]

// Вариант a (контроль): только по названию
pub fn searchByName(q: str) -> List<str> =>
  catalog().filter(e => e.title.contains(q.lower())).map(e => e.sku)

// Вариант b (кандидат): по названию и по тегам
pub fn searchByTag(q: str) -> List<str> =>
  catalog().filter(e => e.title.contains(q.lower()) || e.tags.contains(q.lower())).map(e => e.sku)

// Какой вариант получает запрос, решает эксперимент (§18.5): в `oxl test` — по хешу пользователя на записанном трафике,
// в кластере — динамический Flag, который создаёт `oxl ab start "search v2"`
pub fn searchCatalog(q: str) -> List<str> {
  let found = match ab.variant("search v2") {
    "b" => searchByTag(q)
    _ => searchByName(q)
  }
  if !found.isEmpty() { metric.counter("search_found").inc() }   // пользовательская метрика эксперимента
  found
}

photos/photos.oxl#

oxl
use super.model.*
use super.limits { THUMB_SIZES }

dep blobs: BlobStore                  // хранилище файлов (provide в cluster.oxl)

// Топик «фото готово»: на него подписываются клиенты и индексатор
@retain(count: 1000) pub(api) topic PhotoEvents: PhotoReady
pub(api) data PhotoReady { id: PhotoId; owner: UserId; thumbs: List<str> }

data Photo { id: PhotoId; owner: UserId; image: Image }

// Чистая функция: проверяет формат и создаёт Photo или возвращает BadImage
fn decodePhoto(owner: UserId, b: bytes) -> Photo | BadImage pure {
  let img = Image.decode(b) ?? fail BadImage{ reason: "unsupported format" }
  Photo{ id: PhotoId(cluster.ids.next()), owner, image: img }
}

// Пайплайн: каждый шаг может выполняться на своём узле,
// данные передаются от узла к узлу напрямую, клиент получает только respond.
pipeline uploadPhoto(owner: UserId, img: bytes) -> PhotoId {
  (o, b) => decodePhoto(o, b)                       // 1. на текущем узле; плохой файл — ошибка клиенту
  -> p => { blobs.put("orig/{p.id.raw}", p.image.encode()); p }   // 2. сохраняем оригинал
  -> respond it.id                                  // 3. ответ клиенту: id фото; дальше — фон
  -> id => makeThumbs(id, THUMB_SIZES)      on gpu  // 4. превью на узле с GPU; id — значение respond
  -> moderate                              on ml  durable   // 5. модерация на ML-узле; с этого шага прогресс в журнале
  -> t => emit PhotoEvents(PhotoReady{ id: t.id, owner, thumbs: t.urls })   // 6. сообщаем подписчикам
} on_error(e, step) { log.error("photo pipeline failed at {step}: {e}") }   // ошибки после respond

Image — тип из зависимости imagekit; makeThumbs (принимает PhotoId, читает оригинал из blobs, возвращает Thumbs с id и urls) и moderate (возвращает тот же Thumbs или @error Rejected) лежат в photos/ml.oxl и опущены. Шаг moderate помечен durable, поэтому он и следующий шаг переживут падение узла ml.

cluster.oxl#

oxl
use self.limits.*
use self.config { App }

// Один бинарник на все поды; роль пода определяет, что он поднимает
cluster shop {
  seeds k8s(label: "app=shop")                      // как поды находят друг друга
  auth { jwt(issuer: AUTH_ISSUER); roles from claim("roles") as AppRole }   // проверка токенов пользователей
  defaults { timeout STD_TIMEOUT; drain DRAIN_TIMEOUT }   // значения по умолчанию для всех rpc
  trace_sample 0.05                                 // доля запросов, трассируемых по умолчанию (флаг --trace-sample переопределяет)

  // api: публичные сервисы и корзины, наружу на 443 с сертификатом ACME
  role api     { serves Users, Orders, Shop; hosts Cart; replicas 3..10; expose 443 tls(acme: PUBLIC_DOMAIN) }
  // storage: встроенное хранилище (журнал, топики, состояние акторов), 3 реплики каждого шарда
  role storage { journal; shards STORAGE_SHARDS; replication 3 }
  // saga: выполняет саги и задачу-лидера статистики
  role saga    { runs sagas; runs leader("stats") }
  // gpu и ml: только для шагов пайплайна on gpu / on ml; поды с нужными метками
  role gpu     { needs label("gpu=true") }
  role ml      { needs label("ml=true") }

  // Роль пода: переменная окружения → метка пода → автоматически по нехватке реплик
  assign by env("OXL_ROLE") else label("oxl/role") else auto
}

// Зависимости: создаются один раз на узле при старте.
// Без for role — на всех ролях; клиенты внешних систем нужны только саге.
provide Db = Db.open(App.get().dbUrl)
provide BlobStore = BlobStore.open(App.get().dbUrl)
provide Client<Inventory> = Inventory.connect(App.get().inventoryAddr, tls: {})            for role saga
provide Client<Payments>  = Payments.connect(App.get().paymentsAddr, tls: {},
                                             auth: .Bearer(App.get().paymentsKey))         for role saga
provide Client<Recs>      = Recs.connect(App.get().recsAddr, tls: {})                      for role api

main.oxl#

oxl
use self.orders { StatsUpdates, LIVE_STATS, computeStats }
use self.limits { STATS_PERIOD }

dep db: Db

// main выполняется на каждом узле после старта ролей.
// Узел не останавливается после main, пока размещает сервисы и фоновые задачи.
fn main() net, db, time {
  // Регистрируем задачу-лидера: выполняется ровно на одном узле с runs leader("stats")
  // (роль saga); при его падении — на другом. На остальных узлах вызов ничего не делает.
  cluster.leader("stats") {
    every(STATS_PERIOD) { emit StatsUpdates(computeStats(&db)) }   // раз в 5 с публикуем в топик
  }

  // На каждом узле api: одна подписка на топик → локальный broadcast → все клиенты узла.
  // Так кластерный топик читается один раз на узел, а не по разу на клиента.
  if cluster.self.hasRole("api") {
    node.background.spawn {
      for await s in StatsUpdates.subscribe(from: .Latest) { LIVE_STATS.publish(s) }
    }
  }
}

Тесты#

oxl
// tests/money_test.oxl — обычный юнит-тест
test "money arithmetic" {
  assert Money.rub(10) + Money.rub(5) == Money.rub(15)
  assert encode(Money.rub(1))! == encode(100i64)!      // по сети Money — это i64 в копейках
}

// tests/checkout_test.oxl

// Фикстура: значение до yield получает тест, код после yield — очистка
fixture customer() -> UserId { yield UserId(42) }

// Юнит-тест: узел с ролью all в памяти, сервисы и акторы — в процессе
test "checkout empty cart" with customer as uid {
  asUser(uid) { assert_fails(Shop.checkout(), EmptyCart) }   // вызов от имени пользователя
}

test "payment timeout is retried by saga" with customer as uid {
  // Моки внешних сервисов: ответы по порядку вызовов
  mock Inventory { reserve(_) => ReserveResp{ reservationId: "r1" } always }
  mock Payments  { charge(_)  => fail Timeout once, then ChargeResp{ receiptId: "p1" } }
  asUser(uid) {
    Shop.addToCart(TEST_ITEM)?        // ? в тесте: ошибка проваливает тест
    assert Shop.checkout() is Order
  }
  // Первый charge упал по таймауту, второй прошёл
  assert Payments.calls(.charge).count == 2
  // Оба вызова пришли с одним ключом идемпотентности — деньги не спишутся дважды
  assert Payments.calls(.charge).all(c => c.ctx.idempotencyKey == Payments.calls(.charge)[0].ctx.idempotencyKey)
}

// Симуляция: сеть, часы и планировщик детерминированы, тест прогоняется для 300 seed
@tag(slow)
test sim "checkout survives saga node crash" seeds 1..=300 with customer as uid {
  mock Inventory { reserve(_) => ReserveResp{ reservationId: "r1" } always; release(_) => Ok always }
  mock Payments  { charge(_)  => ChargeResp{ receiptId: "p1" } always; refund(_) => Ok always }
  net.latency(5ms..200ms)             // случайные задержки сети (зависят от seed)
  node.kill("saga-0") after 50ms      // роняем узел саги посреди оформления
  let r = asUser(uid) { Shop.addToCart(TEST_ITEM) -> Shop.checkout() }
  assert r is Order | Timeout
  assert r is Order || Inventory.calls(.release).count >= 1     // либо заказ, либо резерв снят
}

// Интеграционный тест: настоящий кластер из 3 узлов в процессе
@tag(slow, integ)
test integ "order events reach subscriber" with cluster(nodes: 3), customer as uid {
  let sub = asUser(uid) { Orders.watch() }                    // подписываемся до оформления
  asUser(uid) { Shop.addToCart(TEST_ITEM) -> Shop.checkout() }?
  assert (await sub.first()) is OrderEvent.Created            // первое событие — создание заказа
}

// A/B-тест: записанный трафик поиска прогоняется на обоих вариантах, метрики сравниваются со значимостью;
// кандидат, нарушивший guard, проваливает тест (в кластере — откатывает флаг)
test ab "search v2" {
  variant a = searchByName
  variant b = searchByTag
  split by .User 50/50
  metric search_found, latency.p95, error_rate
  guard latency.p95 < SEARCH_P95_LIMIT
  traffic recorded("traffic/search.oxlrec")
}

asUser(id) { … } — функция std.test, выполняющая блок от имени Principal.User; TEST_ITEM — константа в tests/fixtures.oxl.

Запуск, тесты, отладка#

oxl
# Разработка
$ oxl dev --nodes 5                               # весь кластер локально, роли распределяются auto

# Тесты
$ oxl test                                         # по умолчанию: unit + integ + contract
$ oxl test --kind sim --seeds 1..1000 -f checkout  # только симуляции с «checkout» в имени
$ oxl test --tag !slow --changed main              # быстрые тесты, затронутые изменениями от main
$ oxl test --seed 217 --debug                      # воспроизвести упавший seed под отладчиком
$ oxl test --kind ab                               # A/B-тесты на записанном трафике

# A/B-эксперимент в кластере
$ oxl trace --record traffic/search.oxlrec --cluster shop.internal:7000 --for 10m --service shop.checkout.Shop
$ oxl ab start "search v2" --cluster shop.internal:7000   # создаёт Flag и делит трафик
$ oxl ab status "search v2" --cluster shop.internal:7000  # метрики вариантов и значимость

# Сборка и деплой
$ oxl build --profile prod --image registry.local/shop:0.3.0   # статический бинарник в OCI-образе
$ kubectl apply -f k8s/                            # один образ на все поды, роль — метка oxl/role

# Наблюдение и отладка
$ ./shop --role api --inspect-ui                   # веб-интерфейс состояния на 127.0.0.1:8090
$ oxl inspect --cluster shop.internal:7000         # тот же интерфейс с машины разработчика
$ oxl debug enable --node api-2 --for 30m          # разрешить отладку узла на 30 минут
$ oxl debug --node api-2 --cluster shop.internal:7000   # подключиться через кластер, без открытия порта

Клиент на Python#

sh
$ oxl gen --py --out clients/py
python
# Клиент сгенерирован командой oxl gen --py: типы и ошибки совпадают с контрактом Shop
from shop_client import Shop, Item, EmptyCart

shop = Shop("shop.example:443", token=jwt)          # gRPC поверх TLS, токен — JWT пользователя

# price — Money, по сети это i64 в копейках (150000 = 1500 ₽)
shop.add_to_cart(Item(sku="BOOK-1", title="Book", price=150000, qty=1))

try:
    order = shop.checkout()
except EmptyCart:                                    # @error-тип OxL — отдельное исключение Python
    ...

Исходник: docs/spec/oxl-standard.md

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