V.5 Распределённый сервис «shop»
Сервис пользователей, заказов и корзин; оформление заказа сагой с внешними складом и платежами; загрузка фото пайплайном на GPU; события заказов через топик; живая статистика через топик и локальный broadcast.
Структура#
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#
// Все таймауты, лимиты и размеры — здесь и только здесь (часть 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#
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#
// Идентификаторы — отдельные типы: 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#
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#
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#
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#
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#
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#
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}") } // ошибки после respondImage — тип из зависимости imagekit; makeThumbs (принимает PhotoId, читает оригинал из blobs, возвращает Thumbs с id и urls) и moderate (возвращает тот же Thumbs или @error Rejected) лежат в photos/ml.oxl и опущены. Шаг moderate помечен durable, поэтому он и следующий шаг переживут падение узла ml.
cluster.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 apimain.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) }
}
}
}Тесты#
// 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 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#
$ oxl gen --py --out clients/py# Клиент сгенерирован командой 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