OxwaldLink — architecture
Implementation of the OxL language (standard: docs/spec/oxl-standard.md, cited as [С §N];
implementation brief: docs/spec/tz.txt, requirement ids K1…, P1…, И1…).
Scope of the current effort: stages 1–2 of the TZ (language core + concurrency).
Distributed features (services, cluster, storage, …) are parsed but rejected by sema with
not-implemented.
Layout#
| Path | What |
|---|---|
compiler/include/oxl/basic | Source.h (files, spans), Diagnostic.h + DiagCodes.def (all diagnostic codes) |
compiler/include/oxl/lex | Token.h, TokenKinds.def, Lexer.h |
compiler/include/oxl/ast | AST.h (all nodes), ASTDump.h (S-expression printer used by tests) |
compiler/include/oxl/parse | Parser.h |
compiler/include/oxl/lsp | oxl lsp language server (JSON-RPC, documents, analysis snapshots, queries): see docs/lsp.md, D-2800.. |
compiler/include/oxl/format | Formatter.h: oxl fmt / LSP formatting (formatSource, formatRange), D-900.. |
compiler/include/oxl/sema | types, symbols, name resolution, type checking, effect/error/ownership checks |
compiler/include/oxl/codegen | LLVM IR generation |
compiler/include/oxl/driver | package loading (oxl.toml), pipeline, linking |
runtime/ | liboxlrt.a, C ABI in runtime/include/oxl_rt.h |
std/ | standard library sources written in OxL |
tools/oxl | the oxl CLI |
tests/unit/<area> | gtest unit tests, one binary per area |
conformance/§NN.N/*.oxl | conformance suite (see conformance/README.md) |
docs/decisions.md | decisions on ambiguities of the standard |
docs/diagnostics/<code>.md | one page per diagnostic code |
Pipeline#
source ─► Lexer ─► tokens ─► Parser ─► AST ─► Sema ─────────────► typed AST ─► Codegen ─► LLVM IR ─► object ─► link (clang) + liboxlrt.a
├ Resolve (modules, imports, names)
├ TypeCheck (types, inference, overloads, match exhaustiveness)
├ Effects (§6.2) ├ Errors (§6.5) ├ Ownership (§7, @must_close)
└ Lints (ч. III.4)- Comptime reflection (
reflect,inline for,x.[f]) is expanded in sema right before a body is type-checked (sema/Reflect.cpp, D-1900..D-1914); generic functions that reflect on their type parameters are checked per instantiation and codegen calls the clone (Sema::reflectCloneFor). - Diagnostics are never printed directly; everything goes through
DiagEngine. - Each pass is a separate translation unit with its own unit tests.
Value model (codegen ↔ runtime ABI)#
| OxL type | LLVM representation |
|---|---|
i8…i128, u8…u128 | iN (signedness tracked by sema) |
isize, usize | i64 |
f32, f64 | float, double |
bool | i1 in SSA, i8 in memory |
char | i32 (Unicode scalar) |
() | {} (functions returning () return void) |
never | no value (unreachable) |
str, bytes | ptr to OxlStr (refcounted, immortal literals with rc = -1). Copy = oxl_str_retain |
String | ptr to OxlString |
List<T> | ptr to OxlList + per-type OxlTypeDesc |
Map<K,V>, Set<T> | ptr to OxlMap |
tuple, data | LLVM struct, fields in declaration order (embedded type = one field) |
enum without fields | i32 discriminant |
enum with fields | { i32 tag, [N x i64] payload } |
union A | B, T? | { i32 typeTag, [N x i64] payload }; none has tag 0 and no payload |
distinct/refined T | same as T |
Duration | i64 nanoseconds; Time — i64 ns since epoch; Date — i32 days since epoch |
closure / fn value | { ptr fn, ptr env }; fn(env, args…); env is heap-allocated, first word = drop fn |
iter<T> | the closure fn() -> T? that pulls the next element (D-2000, see "Lazy iterators") |
dyn I | { ptr data, ptr vtable } |
&T, &var T | ptr |
Every type used in a collection gets an OxlTypeDesc constant emitted by codegen
(size, align, drop, clone, eq, hash, cmp, show).
Panics unwind with the C++ exception ABI (__gxx_personality_v0); functions that
own values needing drop or have defer use invoke + cleanup landing pads.
Lazy iterators (D-2000..D-2005)#
iter<T> is a synchronous, lazy, single-pass sequence: a pull closure fn() -> T? (the representation of a function value,
BuiltinKind::Iter lowers to closureType()), so the whole chain lives in one reference-counted environment and drop
releases every captured source. The library is plain OxL: std/core/iter.oxl (adapters and consumers written with fromFn and
next), std/core/iter_numbers.oxl (generated by scripts/gen_std_iter_numbers.py: sum/product), four intrinsics in
compiler/src/codegen/CGQuery.cpp.
- Ownership: an
iteris notCopy, adapters and consumersmoveit, functions passed to adapters areescaping;list.iter()copies the list, so an iterator never borrows (nothing to escape).for x in itborrows and advances the iterator (CodeGen::emitFor, kindIterK); the temporaries of the iterable belong to the loop scope. foralso accepts user types withnext() -> T?oriter(), andI: Iterable<T>type parameters (Sema::elementTypeOf).- Query expressions (§5.9) are lowered by
Sema::checkQueryinto adapter calls (QueryExpr::lowered); the clause expressions are moved into lambdas, the binding pattern is copied per clause, so the usual type, effect and ownership checks apply.order by/group byare barriers that drain their input at the first pull.
Profiles (ч. III.2)#
| profile | opt | debug-info | contracts | trace | panic |
|---|---|---|---|---|---|
| dev | 0 | full | on | on | unwind |
| test | 1 | full | on | on | unwind |
| prod | 3 | line | off | off | unwind |
debug-info is full (DWARF 5 with variables and types), line (line tables only) or none; override with
[profile.<name>] debug-info = "…" or oxl build --debug-info none|line|full. [profile.<name>] lto = true links with
-flto (bitcode objects, lld). Builds are reproducible (byte-identical). See D-1400..D-1403.
Concurrency runtime (stage 2)#
M:N stackful tasks on a work-stealing pool (one worker per core), context switch in
assembly (x86-64, aarch64), stacks = reserved virtual memory with a guard page and
on-demand commit. File I/O through one process-wide io_uring plus a completion thread (Linux, D-1600..1604; blocking-pool fallback, kqueue not done). Preemption of tasks running >10 ms via
a monitor thread + signal. scope, cancellation, deadlines, channels, select,
local actors.
Serialization (stage 3, [С §9], D-2100..D-2114)#
Codecs are descriptor driven: instead of one encoder/decoder function per type the compiler emits a constant graph of
OxlSerNodes (runtime/include/oxl_ser.h) and liboxlrt (runtime/src/ser_{bin,json,common,api}.cpp) walks it against a value in
its lowered memory layout. Offsets and sizes come from the LLVM data layout, so the runtime never guesses. One implementation serves both
formats (binary = protobuf 3 wire encoding, JSON = canonical protobuf JSON), the node graph is the inspectable schema, and decoding is
all-or-nothing (the partially built value is dropped on error).
| layer | where | what |
|---|---|---|
| sema query | Sema::isSerializable(Type, &why) (sema/Serial.cpp) | Serializable (§9.1); satisfies(T, Serializable) uses it |
| schema description | ser::schemaOf(Sema&, TypeSymbol*) -> TypeSchema (oxl/sema/Serial.h) | tags (@tag / lock / max+1), @v, @since, @removed, @renamed, @zigzag, @repr, @codec; the structure a future oxl export (.proto) generator reads |
| lock file | ser::LockFile, parseLock, writeLock, buildLock, compareLocks (sema/SerialLock.cpp) | oxl.schema.lock (прил. C), the §9.2 compatibility table; compareLocks(old, cur) is also what oxl publish needs |
| codegen API | CodeGen::serNode(T), emitEncode(T), emitDecode(T) (codegen/CGSerial.cpp) | serNode = global OxlSerNode; ptr encode(ptr value, i32 format, ptr err) and i1 decode(ptr bytes, i32 format, ptr out, ptr err), err an OxlSerError; formats 0 binary, 1 JSON |
| runtime | oxl_ser_encode/decode, oxl_enc_*/oxl_dec_* (codec objects) | varint/zigzag/fixed, packed repeated, maps, oneofs, time messages, base64, JSON parser |
| std | std/ser/ser.oxl, std/ser/formats/formats.oxl | encode/decode/toJson/fromJson/bytesOf/textOf/hex/fromHex, Format, Repr<W>, Codec<T>, Encoder, Decoder, EncodeError, DecodeError |
How a later task uses it (rpc, topics, storage): registerWireType(T) so the type is persisted in the lock, isSerializable for the
compile error, then call emitEncode(T) / emitDecode(T) from generated code. For a type with a node graph
ser::schemaOf(sym).fields[i].tag is the field number a .proto exporter must print; CodeGen::serNode shows the exact kinds
(message, enum, oneof, union, repeated, map, wrap, repr, codec). The node ABI is pinned by tests/unit/codegen/serial_test.cpp.
Memory facts the runtime depends on (all in the node): a union is { i32 tag, payload at +8 } with the tag a type id (none has the
id of none, none_discr); an enum with fields is { i32 variant index, payload at +8 }, a fieldless enum an i32 discriminant (an
@open enum has the automatic Unknown(tag: u32) and therefore a payload); str/bytes are OxlStr*, List OxlList*,
Map/Set OxlMap*. Custom codecs (@codec) and @repr are thunks the codegen emits next to the node (CODEC / REPR kinds).
oxl.schema.lock is handled by the driver (Compilation::check): read next to oxl.toml, updated by oxl build/run, --locked and
--against options; sema builds the lock in finishSerialization and compares it with the previous one (schema-break).
Interop with other implementations is tested against protoc in tests/ser_interop/ (CTest ser_interop). MessagePack and CBOR (D-7600) are one walker in runtime/src/ser_doc.cpp (a Writer per format on the way out, a parsed document tree on the way in);
the same walker drives level-3 user formats (D-7620): the compiler passes the dyn Encoder of Format.encoder() (data pointer + vtable) to
oxl_ser_encode_custom, which calls the vtable slots in the order of std/ser/ser.oxl (OXL_ENC_*). @preserve_unknown (D-7610) adds a hidden
OxlStr* slot to the lowered struct (CodeGen::hasUnknownSlot), flagged OXL_SN_PRESERVE in the node. Not done: decoding with a
user format (D-7621), decode_view, ActorRef addresses, oxl export itself.
Network I/O (stage 3, D-2200..D-2207)#
Layers, bottom up:
| Layer | Where | Notes |
|---|---|---|
| ring ops | runtime/src/sched/io_uring.cpp, sched/io.h (NetOp, ioNet) | accept/connect/recv/send on the process-wide ring, linked timeout for the deadline |
| poller | runtime/src/net/poller.cpp | no io_uring (OXL_NO_IO_URING=1, old kernel) and non-task threads: non-blocking sockets + one epoll thread, poll(2) outside tasks |
| C++ API | runtime/src/net/net.h, net.cpp (namespace oxl::rt::net) | what TLS / HTTP/2 / gRPC runtime code calls |
| C ABI | oxl_rt.h oxl_net_* (net_abi.cpp) | for FFI-like callers; oxl_netx_* are the helpers behind compiler intrinsics |
| std.net | std/net/net.oxl, compiler/src/codegen/CGNet.cpp | IpAddr, SocketAddr, TcpListener, TcpStream, dns; effect net |
C++ API contract (#include "net/net.h", all in oxl::rt::net):
- A socket is an
intfd;Socketis a move-only owner (closes withnet::close). Calls return>= 0or-errno(name lookup:-(kDnsErrBase + EAI_*));errorText(err)/classify(err)give text and theNetErrorclass. socketOpen,bind,listen,setOption(NoDelay|ReuseAddr|ReusePort|KeepAlive|V6Only|SendBuf|RecvBuf),localAddr,peerAddr,shutdown,close,adopt(fd)never park.accept(fd, &peer, deadline),connect(fd, addr, deadline),readSome,writeSome,writeAll,readExact,resolve(host, port, out, deadline)park only the calling task (a plain thread blocks) and are cancellation points. Convenience:listenTcp,listenUnix,connectTcp,connectUnix,connectHost.deadlineis absolute monotonic ns (deadlineAfter(timeoutNs); 0 = none). Pending at the deadline ->-ETIMEDOUT; an expired deadline fails at once. A cancelled task (or one past its chain deadline, outsideshield) getsCancelledthrown from the call; RAIISockets anddefers unwind normally.Addrwrapssockaddr_storage(parse,parseIp,unixPath,host(),port(),str());readSomereturns 0 at end of stream; sends useMSG_NOSIGNAL. A descriptor must not be closed while another task waits on it (ring mode:shutdownfirst).- For TLS: read/write the raw fd through
readSome/writeSomefrom the BIO callbacks; they are safe to call from any task and from the blocking pool (plain threads usepoll).
Static configuration and secret<T> (stage 3, D-2700..D-2710)#
config Name { field: T = default … profile p { field = v } } is a record type plus Name.get() -> Arc<frozen Name>:
| Part | Where | Notes |
|---|---|---|
| declaration | compiler/src/sema/ConfigSema.cpp, Declare.cpp, TypeDecls.cpp | a Data symbol; get is a synthesized impl with intrinsic config.get; field types must be readable from text (config-type); profile values are type-checked |
| loader | runtime/src/config/{toml,config,config_abi}.* | TOML subset parser; Loader reads --profile, --set, OXL_PROFILE, <CONFIG>_<PATH> variables and config/<profile>.toml; converts text to numbers/str/Duration/enums/lists; collects every problem; finish() prints them and returns 78 |
| generated startup | compiler/src/codegen/CGConfig.cpp | oxl.config.load() runs first in oxl.user_main (and in the oxl test process): per leaf field lookup → decode, else profile block value, else default; refinements and invariants are checked; the value lives in an Arc box in a global |
secret<T> | std/core/types.oxl, CGTypes.cpp, CGConfig.cpp, runtime/src/config/config_abi.cpp | lowered as T; Show = ***; secret<str> owns a private buffer wiped on drop; reveal() copies |
Sources, precedence, naming and text forms: D-2701..D-2705. Errors (exit code 78): D-2706. get() ordering: D-2707.
Динамический конфиг и флаги (stage 4, wave 6 D, D-5190..D-5203)#
dynamic Limits { rps: u32 = 100 newSearch: Flag = Flag.off } ([С §14.2]) — значения, которые живут в кластере и меняются без перезапуска. Весь путь:
| Часть | Где | Что делает |
|---|---|---|
| объявление | compiler/src/sema/DynamicSema.cpp, Declare.cpp, TypeDecls.cpp | dynamic → тип-запись, поля которой — живые дескрипторы: f: T = e → f: DynHandle<T>, Flag остаётся Flag. Синтезируется impl Limits { static fn get(); static fn dynPolicy(caller, field, layer, value) -> bool } как обычное синтаксическое дерево (значения по умолчанию переезжают в аргументы DynHandle.open), поэтому типы, вывод и кодогенерация — штатные. Встроенное уточнение x: T where P — анонимный уточнённый тип (синтезированный алиас Limits.x) |
| политика | тот же файл | policy Limits { allow set if … } → тело dynPolicy (deny > allow > отказ); без блока policy — разрешено всем, кто достучался до сокета / коду программы |
| запуск | codegen/CodeGen.cpp | точка входа вызывает Limits.get() каждого dynamic-конфига после запуска сервисов, поэтому поля есть до первого обращения кода |
| прелюдия | std/core/dynconf.oxl | DynHandle<T> (get, changes, version, set/setAs, unset/unsetAs, rollback, check), Flag, DynScope, FlagBy, DynError; open регистрирует поле и запускает демон-задачу (node.background.spawnDaemon — не удерживает узел), которая обслуживает запросы oxl config: политика, затем разбор текста как T (fromJson<T>: уточнения и инварианты проверяются так же, как у проводных данных) |
| реестр узла | runtime/src/dynconf/{dynconf,registry}.cpp | документ поля: переопределения по слоям, версия, история 40 изменений; иерархия tenant > node > zone > role > global > default; флаги (процент, by, allow/deny, FNV-1a + финализатор murmur3); кэш, наблюдатели .changes(), очередь запросов к OxL-стороне |
| хранилище | registry.cpp (файл <data-dir>/dynconf.state), dyn_cluster.cpp | кластер: ключ dyn/<Cfg>.<field> системного KV группы 0 (CAS), кэш узла идёт за опубликованным представлением координатора (View::dyn, опрос каждые 40 мс); один узел: локальный файл с теми же документами |
| ABI | runtime/src/dynconf/abi.cpp | rt:oxl_dyn_* |
| CLI | tools/oxl/cmd_config.cpp, runtime/src/dynconf/control.cpp | oxl config get|set|unset|list|history|rollback; сокет <data-dir>/config.sock узла; set → очередь запроса задаче программы (политика + тип) → фиксация (CAS) → запись аудита (config.set, Limits.rps, принципал, allow|deny) |
Цель распространения ≤ 1 с, best effort: на нагруженном 4-ядерном хосте с 3 процессами измерено 0,2–0,7 с до значения на всех узлах. Тесты: tests/unit/runtime/dynconf_test.cpp
(иерархия, детерминизм хеша флагов, отказ уточнению, история и откат, хранилище, очередь запросов, control-строки), conformance/§14.2/dynamic-*.oxl (язык),
tests/dynconf/test_dynconf_cluster.py (ctest cluster_dynconf: oxl dev --nodes 3, set на одном узле виден на всех, .changes() срабатывает, откат,
отказ по политике с аудитом, флаги). Решения и пробелы: D-5190..D-5203.
static globals (stage 3, D-3480..D-3483)#
static NAME: T = e is a lazily, once-initialized global (a similar lazily-initialized global to the config box, but per static and
initialized on first access). Sema: Sema::isStaticSafe (global-mutable for non-frozen/non-sync T, D-3481), assignment is not-assignable.
Codegen (codegen/CGStatic.cpp): per static a slot global, a once-cell { i64 state, i64 owner } and an oxl.static.NAME.init function; an access
is load acquire state == 2 ? slot : oxl_static_once(cell, init, name, loc) and yields the slot address (emitPlace) or a clone of it
(emitStaticValue). Runtime (runtime/src/static_once.cpp): CAS UNINIT -> RUNNING, the winner runs the initializer, others oxl_task_yield()
until DONE; the same task re-entering panics (recursion), a panicking initializer poisons the cell. The value is never dropped. Literal
initializers skip all of this (CodeGen::isTrivialStaticInit).
TLS (stage 3, D-2400..D-2406)#
TLS 1.2/1.3 sessions layered on oxl::rt::net sockets with memory BIOs, so socket I/O stays task-aware (io_uring or the epoll poller),
honours deadlines and is a cancellation point (D-2202). The library is the OpenSSL-compatible libssl API; CMake option
OXL_TLS_BACKEND=openssl|awslc (default openssl >= 3.0; awslc + OXL_AWSLC_ROOT compiles with OXL_TLS_AWSLC guards, untested).
| Layer | Where | Notes |
|---|---|---|
| C++ API | runtime/src/net/tls.h, tls.cpp (namespace oxl::rt::net::tls) | Options, Context (shared SSL_CTX), Session — what HTTP/2 / gRPC runtime code calls |
| C ABI | oxl_rt.h oxl_tls_* (tls_abi.cpp) | handle table (config/context/session); oxl_tlsx_* are the string helpers behind compiler intrinsics |
| std.net | std/net/tls.oxl, compiler/src/codegen/CGTls.cpp | TlsConfig, TlsStream, TlsListener; effect net |
C++ API contract (#include "net/tls.h"):
Context::create(Options, &error)loads trust anchors (system roots orcaFile/caPem), identity (certFile|certPemleaf + intermediates,keyFile|keyPem,keyPassword), ALPN list, versions (minVersion/maxVersion= 0x0303/0x0304),clientAuth(server mTLS),pinSha256,verifyPeer(false = insecure, tests),verifyHostname. It is immutable and shareable between sessions and threads.Session(ctx, net::Socket, serverName)owns the connected socket.handshake(deadline),read,readExact,write(all bytes),shutdown(close_notify, half close),closeNow(never parks), thenalpn()(e.g."h2"),version(),cipher(),sni()(server),peerSubject(),peerCertDer(),peerCertSha256(),verifyResult(),lastError()/lastErr(). Results are>= 0or-errno/-(200000 + Err)(tls::code(Err::X));tls::errorText,tls::classify,tls::isTlsError.- Concurrency: one reader plus one writer concurrently (separate tasks), more of either queue;
readreturns 0 only for a received close_notify (Err::Truncatedotherwise); a read timeout is recoverable, a failed write or handshake poisons the session. - For HTTP/2: offer
alpn = {"h2", "http/1.1"}(client) or support them in preference order (server) and checksession.alpn() == "h2"; TLS 1.2 with h2 relies on libssl's default cipher list (no cipher restriction is applied). - Tests:
tests/unit/runtime/tls_test.cpp(in-memory CA fromtls_test_certs.h; both io backends; interop withopenssl s_client/s_serverwhen installed),tests/unit/codegen/tls_test.cpp, conformance§II.1/tls-*.oxl(fixtures intests/fixtures/tls, test only).
Standard library MVP (stage 3, W3-L, D-3100..D-3160)#
How the new std modules bind to the runtime, and where each piece lives:
| Module | OxL | Compiler | Runtime |
|---|---|---|---|
std.time (§17) | std/time/time.oxl; Tz, Cron in the prelude | tz"…", cron"…", sha256"…" literals (CheckExpr.cpp), 3.days sugar (ParseExpr.cpp), {t:pattern} (CGTypes.cpp emitShow) | runtime/src/timelib/ — tz.cpp (calendar, TZif + POSIX rules), timefmt.cpp (patterns, ISO 8601, cron), time_abi.cpp |
std.obs log (§15.5) | std/obs/obs.oxl | CGLog.cpp: log.info("x {y}") is lowered while its arguments are emitted (level test first, interpolated values become fields) | runtime/src/obs/log.cpp |
std.crypto | std/crypto/crypto.oxl | none | runtime/src/crypto/crypto.cpp (libcrypto EVP only) |
std.io files / dirs / Path | std/io/io.oxl | none | runtime/src/fs/fs.cpp (io layer for file contents, blocking pool for the rest) |
std.text Regex, Utf8, string helpers | std/core/text.oxl | re"…" and matches(re"…") checked with the same PCRE2 code (runtime/src/text/regex_core.cpp is compiled into oxlc) | runtime/src/text/ (PCRE2) |
std.orm (SQLite) | std/orm/orm.oxl; Db, Table<T>, DbError in the prelude | CheckTable.cpp (table declarations, db.User, query → SQL), CGOrm.cpp (table descriptors, intrinsics) | runtime/src/orm/orm.cpp, runtime/include/oxl_orm.h |
| test helpers (§18.2) | assert_fails in std/core/functions.oxl | CGControl.cpp (operands of assert), CGRtCall.cpp (test.assertFails) | — |
rt: intrinsics (D-3100). @intrinsic("rt:oxl_xyz") on a body-less OxL function calls the C function oxl_xyz directly; the OxL signature fixes the
C one (integers/bool as int64_t, floats as double, str/bytes as borrowed OxlStr*, &var T as a pointer, results int64_t/double/owned
OxlStr*/void). Adding a runtime function needs no compiler change: declare it in the module and write the C++ (CGRtCall.cpp). Anything richer
(errors, optionals, records) is built in OxL, as std.net does.
ORM tables. CGOrm.cpp emits one constant OxlOrmTable per @table type used by a program: SQL name, the type's OxlSerNode (std.ser: field names,
offsets, kinds), the column flags (@pk, @auto) and the indexes. The runtime reads and writes a row through the node, so scalars become columns and
everything else a serialized column without per-type generated code. Queries over db.T are translated in sema (checkTableQuery) to SQL text with ?N
parameters; the parameters are ordinary expressions bound at run time.
Dependencies added to every OxL program: libpcre2-8 and libsqlite3 (scripts/install_deps.sh); libssl/libcrypto were already linked for TLS.
Streams and Broadcast (stage 3, [С §10.3, §10.4], D-2600..D-2619)#
stream<T, E = never> is a single-consumer, affine, counted handle (OxlStream*) of an asynchronous sequence that ends normally or with one
error value of E. Local streams pass elements without serialization; the same object type is meant to carry remote streams (wave 3).
| layer | where | what |
|---|---|---|
| sema | sema/Streams.cpp | detectGenerator (declared stream<T, E> result + yield outside lambdas), checkYield, checkForAwait; the generator body is checked against () / () | E (Sema::generators_); FnCtx::isGenerator/yieldType; diagnostic yield-outside-stream |
| codegen | codegen/CGStream.cpp | emitGeneratorStart, emitYield, emitForAwait, intrinsics stream.pull, stream.lagged, bcast.* |
| runtime | runtime/src/sched/stream.cpp, broadcast.cpp | the stream object and producer-task protocol; the Broadcast hub; C ABI oxl_stream_*, oxl_bcast_* in oxl_rt.h |
| std | std/core/stream.oxl, std/stream/stream.oxl | Pulled, pull, pullWithin, results, lagged; operators; Broadcast, SlowPolicy |
Generator. fn gen(a, b) -> stream<T, E> { … yield x … } compiles to a closure body (emitClosureFunction, captures = the moved
parameters) and an entry thunk that runs it and hands an error result to oxl_stream_fail. The function itself only calls
oxl_stream_new_gen(elemDesc, errDesc, entry, env, oxl_env_release); nothing runs until the first oxl_stream_next, which spawns the body as a
producer task (node background scope). Protocol (demand driven, buffer = one element): a pull sets want; the producer runs to its next
yield, which stores the element in the slot and suspends until the slot was emptied and a new pull arrived. next returns
OXL_STREAM_ITEM, OXL_STREAM_ERROR (once, then END), OXL_STREAM_END, and next_within also OXL_STREAM_TIMEOUT (the demand stays).
Every wait is a cancellation point. oxl_stream_close (drop glue; the last of the handles oxl_stream_retain made) cancels the producer,
waits until its task is finished (defers and drops have run), drops an undelivered element and releases the environment. A producer panic is
re-raised in the consumer's pull.
Source streams: the hook for wave 3. oxl_stream_new_source(elemDesc, errDesc, &vtable, ctx) makes a stream whose elements come from
callbacks that run on the consumer's task and may park it:
typedef struct OxlStreamSource {
int32_t (*pull)(void* ctx, void* elem, void* err); // ITEM / ERROR (once, then finished) / END
void (*close)(void* ctx); // exactly once, when the last handle is dropped
void (*cancel)(void* ctx); // optional: oxl_stream_cancel
uint64_t (*lagged)(void* ctx); // optional
int32_t (*pull_within)(void* ctx, void* elem, void* err, int64_t deadline_ns); // optional: TIMEOUT
} OxlStreamSource;A remote server-stream rpc is such a source: pull reads the next message (parking the task, deadlines and cancellation as in D-2202) and
decodes it straight into elem (lowered layout of T); a transport failure or trailing status is written to err as E | NetError (the
compiler passes the descriptor of that union); close cancels the call. A client-stream/bidi parameter input: stream<A> is consumed by the
server glue with oxl_stream_next in a task that encodes onto the wire.
Broadcast (oxl_bcast_*): a hub with one ring of Shared<T> values (Arc<frozen T>, one pointer shared by reference count) and a cursor
per subscription. publish consumes the element; .Block parks the publisher while a live subscriber is full, .DropOldest advances a full
subscriber's cursor at publish time (lagged is exact), .Disconnect lets it deliver what it holds, then ends with Lagged { missed }. A
subscription is a source stream. One mutex per hub; publishers and subscribers may be tasks on any worker or plain threads. Network fan-out
should subscribe one local stream per client and encode each element once, keyed by the Arc pointer. Pipelines (x |> map(f) |> take(3))
are chains of generators; dropping the end of the chain cancels the stages one after the other.
Services and rpc (stage 3, D-2900..D-2914)#
service / rpc / impl / dep / provide / cluster / policy are compiled by sema/ServiceSema.cpp + ServiceCheck.cpp (data for codegen: oxl/sema/Service.h),
codegen/CGService.cpp, and the runtime runtime/src/rpc/rpc.cpp (C ABI and descriptors: runtime/include/oxl_rpc.h). std side: std/core/rpc.oxl,
std/core/node.oxl (prelude), std/rpc, std/cluster.
| Piece | Where | What |
|---|---|---|
| contract | resolveServiceContract | service = SymKind::Data type, rpc = static intrinsic rpc.call returning R | E | NetError; Client<S> methods via Sema::clientMethod; synthetic data request (S.GetRequest, params as fields, @tag kept) and response ({ result: V }) |
| impl | bindServiceImpl, finalizeServices | one ordinary function per rpc (the handler); ctx / dep bound by inserted lets; contract checks (impl-signature-mismatch, …) |
| provide / dep | finalizeServices, checkServices | factory function + global slot per provide; dep accessor (rpc.dep) returns &T; coverage per role |
| attributes | checkServices | §10.2 table evaluated at compile time into RpcInfo |
| call site | rpc.call in CGService.cpp | args → request block → oxl.rpc.<S>.<m>.call(block, clientKind, target) |
| runtime | oxl_rpc_run | handler task with its own ctx and deadline min(caller, now + @timeout); rate limit, response cache, idempotency; panic → INTERNAL; caller cancellation reaches the handler |
| startup | CodeGen::emitServiceStartup, oxl_rt_start | §2.4 3–7: roles (assign by), oxl_node_configure, provides in declaration order, oxl_rpc_register, main, then serve until SIGTERM/SIGINT and drain |
Per rpc codegen emits (names oxl.rpc.<pkg.S>.<m>.*): tramp(args, result) (the handler task body: clones the request block, calls the handler),
drop, res_clone/res_drop/res_is_error, key* (cache/idempotency/rate-limit keys), call (client side: route, run, retry, outcome → NetError),
wire (server side of a transport) and the OxlRpcMethod descriptor.
Seam for the network transport (W3-J2)#
- Server.
oxl_rpc_find("/pkg.Service/method")gives theOxlRpcMethod:kind(unary / server-stream / client-stream / bidi),flags(readonly,idempotent,public,internal,resumable),timeout_ns,request/response=OxlSerNodemessage nodes (field 1 of the response is the value;ser::schemaOf(S.requestType)is what anoxl export.proto generator reads).m->wire(&inbound, bytes, len, &reply)decodes the request, runs the full call path (oxl_rpc_run: ctx frominbound— trace id, metadata,x-oxl-user,grpc-timeoutasdeadline_ns) and fillsOxlRpcReply:outcome, gRPCstatus,body(encoded response) ordetail_type/detail(typed error detail),retry_after_ns. Streaming kinds go throughOxlRpcStreamOpsinstead (see below). Calloxl_node_set_listening(true)once the listener accepts clients (a served service then keeps the node alive aftermain, D-2907). - Client. A call site asks
oxl_rpc_route(m, clientKind, target)(0 local, 1 transport, 2 nobody →Unavailable; kind 0 =S.m(), 1 =S.at(node), 2 =S.connect(addr)). Route 1 encodes the request message, callsoxl_rpc_transport_call(m, kind, target, req, &reply, &detailType, &detail)and decodes the reply (OK→ response message;APP_ERROR→ decodedetailas the error type nameddetail_type; other outcomes →NetError). The transport installs itself withoxl_rpc_set_transport(fn); without one the route isUnavailable. - Test seam.
OXL_RPC_LOOPBACK=1installs a transport that callswirein process and routes local calls through it;std.rpc.callRaw / callStatus / callDetailscallwiredirectly.tests/unit/codegen/service_test.cppruns the same program both ways.
Runtime notes: handlers are tasks of a private scope of the call, so cancelling the caller (or its deadline) cancels the handler once and the call
returns after it finished; ctx is a refcounted block in Task::rpcCtx inherited by children (SpawnInit lets a call give its handler its own);
the node state (roles, drain flag, registered services, in-flight set) is process wide. Open: streaming rpcs, middleware attributes, enforcement of
policy / @allow, the built-in storage behind @idempotent (stage 4).
Services over the network and oxl export (stage 3, W3-J2, D-3000..D-3042)#
| Piece | Where | What |
|---|---|---|
| listener | runtime/src/rpc/net.cpp (netStart) | started by oxl_rpc_register when the node serves something and an address is configured: --listen / OXL_LISTEN, else expose N of an active role (codegen: oxl_node_expose), else none (D-3001); TLS from tls(cert:, key:) or --tls-cert/--tls-key; every served rpc is added to a grpc::Server under /pkg.module.Service/rpc; health on |
| unary server | net.cpp handleUnary | OxlRpcInbound from the call → m->wire (decode, oxl_rpc_run, encode) → status: OK body, typed detail for domain errors, §10.1 table for NetError, INVALID_ARGUMENT for undecodable requests (D-3004) |
| client | net.cpp netTransport, channelFor | oxl_rpc_set_transport; channel per target string; tls: options inside the target string (oxl_rpc_client_target); ctx.meta, trace and the chain deadline travel with the call |
| streams | net_stream.cpp | oxl_rpc_stream_call (route 0 local: handler stream + lift_err source; route 1: Remote source over a ClientCall; else a stream that fails at the first pull); handleServerStream pulls the handler's stream in the call task and sends one message per element. Typed helpers OxlRpcStreamOps are emitted per rpc by emitStreamRpc (CGService.cpp) |
| SIGTERM | oxl_node_serve_until_signal | Server::shutdown(drain), then the node drain (D-3005) |
| export | compiler/src/sema/ExportProto.cpp, tools/oxl/cmd_export.cpp | exportProto(Sema&) → ProtoFiles; tags from ser::schemaOf; layout and mapping in D-3030/D-3031; CTest services_grpcurl (tests/services) builds a multi-module package, exports, validates with protoc and calls it with grpcurl |
Stream parameters, @resumable, encode once (wave 1 A, D-3600..D-3604)#
| Piece | Where | What |
|---|---|---|
| stream parameter | sema RpcInfo::wireRequestType; codegen emitInOps (CGService.cpp); runtime net_stream.cpp | client-stream / bidi over gRPC: message 1 = the plain parameters (<Rpc>WireRequest, all fields optional), messages 2.. = one element each in the stream parameter's field, then half-close. OxlRpcInOps thunks (enc_first/enc_elem/get_in/dec_first/dec_elem/put_in) are reached through OxlRpcMethod::in; client-stream: oxl_rpc_client_stream_call; bidi: Remote source + pump task; server: handleClientStream / handleServerStream with an input source over ServerCall::recv. A first message that carries an element (grpcurl) counts as the first element |
@resumable | net_stream.cpp (appendOffset, scanOffset, Remote), rpc.cpp (runStreamHandler), std/core/stream.oxl | every response message of a resumable server stream gets protobuf field 2 = its offset; metadata x-oxl-resume-from = offset to continue at (becomes ctx.resumeFrom); the client source reconnects after Unavailable (25 ms doubling, 8 tries) with last offset + 1. OxlStreamSource.offset/resume, oxl_stream_offset/resume; stream.offset(), resumeFrom(n), withOffsets(). Fault injection: std.test { net }.dropConnections() → oxl_net_drop_connections |
| encode once | net_stream.cpp encodeItem | OxlRpcStreamOps::elem_shared (element Shared<T>): the encoded message is cached per box (64 entries), so N connections of a Broadcast encode each element once |
| effects | ServiceSema.cpp | every rpc call, stream rpcs included, requires net at the call site (D-3603, answered) |
oxl export --proto [path] [--out dir] [--all] writes one file per module (shop/catalog.proto for module catalog of package shop, package shop.catalog) and
oxl/types.proto for Date, LocalDateTime, ActorRef. Not done: --openapi, at(node) / cluster routing, ACME, mTLS between nodes (stream parameters, @resumable and encode-once are in the next section).
HTTP/2 and gRPC (stage 3, D-2500..D-2550)#
HTTP/2 over net sockets or TLS sessions with nghttp2 for framing/HPACK, and gRPC on top of it (framing, status, metadata, deadlines). Payloads are opaque
protobuf bytes: the compiler's service/rpc codegen encodes and decodes, this layer moves them.
| Layer | Where | Notes |
|---|---|---|
| h2 connection | runtime/src/http2/h2.h, h2.cpp (oxl::rt::h2) | Transport (plain/TLS), Conn (reader + writer task, one mutex), Stream, Settings, dial |
| task-aware wait | http2/sync.h | Cond: park a task / block a thread, absolute deadline, cancellation point |
| gRPC | runtime/src/grpc/grpc.h, wire.cpp, server.cpp, client.cpp (oxl::rt::grpc) | Server/ServerCall, Channel/ClientCall, Status, mappings, wire helpers |
| C ABI | oxl_rt.h oxl_grpc_*, oxl_h2_* (grpc/grpc_abi.cpp, http2/h2_abi.cpp) | what the compiler's generated code calls |
| tests | tests/unit/runtime/{h2,grpc,grpc_abi}_test.cpp, tests/interop/ | both io backends; grpcurl interop in ctest |
Model#
- One
h2::Connper connection: a reader task (transport ->nghttp2_session_mem_recv) and a writer task (mem_send-> transport), so writes are serialised by construction. All nghttp2/stream state is behind one mutex never held across a parking call; waits useh2::Cond. Connection tasks live in the background scope with a cleared deadline. - Flow control = backpressure (D-2502, D-2508): receive windows are returned to the peer only when the application reads (
Stream::read);Stream::write/ gRPCsendpark while more than 256 KiB are queued behind the peer's window. A slow consumer therefore suspends the producer; other streams of the connection keep flowing (connection window 4 MiB).@window(n)(default 64) is a message-credit window on top: between two OxL runtimes (SETTINGS0x4f58, frame type0xf0, ignored by every other implementation) the producer parks before message n+1 until the consumer's application has taken one out, so it is never more than n messages ahead; towards foreign peers it degrades to "n messages queued". - Cancellation (D-2503): RST_STREAM from the peer, a lost connection or a drain that gives up cancels the handler task bound to the stream; a gRPC server call
runs with the client's
grpc-timeoutas the chain deadline of its task (§8.6), so every wait inside the handler is bounded and a passed deadline ends the call with DEADLINE_EXCEEDED. A client task that is cancelled (or past its deadline) resets its call's stream. - Drain (D-2504, D-2522):
Server::shutdown(grace)/Conn::drain= GOAWAY(2^31-1), PING, GOAWAY(last), wait, reset leftovers, close. Needed for SIGTERM. Status mapping (D-2511, §10.1) —
grpc::codeFromNet/netClassFromCode, C:oxl_grpc_code_from_net/oxl_grpc_net_from_code:NetError gRPC status back Timeout DEADLINE_EXCEEDED (4) 4 -> Timeout Unavailable UNAVAILABLE (14) 14 -> Unavailable Cancelled CANCELLED (1) 1 -> Cancelled AccessDenied PERMISSION_DENIED (7) or UNAUTHENTICATED (16), caller's choice both -> AccessDenied ProtocolError INTERNAL (13) every other code (2, 3, 5, 6, 8..13, 15) -> ProtocolError Typed service errors are not NetError: the codegen sends
@grpc_status(code)(default FAILED_PRECONDITION) with the error value as a detail (Detail= type URL + serialized message insidegrpc-status-details-bin, a standardgoogle.rpc.Status).
C++ API (what the C ABI wraps)#
grpc::Server srv(opts); // opts.tls null = h2c
srv.addMethod("/shop.Orders/Get", grpc::Kind::Unary, handler, user, {.maxTimeoutNs = 2s /*@timeout*/, .window = 0});
srv.listen(addr); /* port 0 -> srv.localAddr().port() */ srv.shutdown(30s); // GOAWAY drain, parks
void handler(grpc::ServerCall* c, void* user) { // fresh task; chain deadline = call deadline
std::string req; c->recv(&req); // 1 message, 0 = client done, <0 = cancelled / malformed
c->meta("x-oxl-user"); c->deadline(); c->setHeader(..); c->setTrailer(..);
c->send(resp); // parks under backpressure
c->finish(Status{Code::FailedPrecondition, "msg", {{typeUrl, bytes}}}); // or return: OK
}
auto ch = grpc::Channel::create("10.0.0.5:7000", {.tls = ctx, .serverName = "orders"}, &err);
grpc::CallOptions co; co.timeoutNs = 2s; co.add("x-oxl-user", "42"); co.inherit(*serverCall); // ctx propagation
Status st = ch->unary("/shop.Orders/Get", req, &resp, co);
auto call = ch->start(path, co, &st); // call->send / closeSend / recv / initialMetadata / status / cancelAll four rpc kinds use the same stream API; the Kind given at registration makes the framework pre-read the single request of unary/server-stream calls and
enforce one response for unary/client-stream.
C ABI and a unary call#
oxl_grpc_* in oxl_rt.h (documented there with the same example). Sequence of Users.connect(addr).get(id) with a handler on the other side:
client task (generated code) client reader/writer server reader/writer server call task (handler)
oxl_grpc_channel_new(target,tls)
call = oxl_grpc_call_new(ch,"/Users/get")
oxl_grpc_call_set_timeout / add_meta
oxl_grpc_call_unary(call, req, n)
Channel::getConn -> h2::dial: TCP connect, TLS ALPN h2,
SETTINGS exchange (awaitReady)
open(HEADERS :path, grpc-timeout=1999999u,
content-type, x-oxl-user) ----writer----> HEADERS ------reader-----> new stream, spawn task
write(0x00 len32 payload) + END_STREAM ----> DATA(END_STREAM) --------> stream buffer runCall: parse grpc-timeout,
recv() parks (deadline = call deadline) oxl_deadline_push, find method,
pre-read request, call handler
oxl_grpc_call_recv -> request bytes
oxl_grpc_call_send -> HEADERS(200)+DATA
<-------------- HEADERS, DATA ------- writer <------------ (flow control: parks if the window is full)
wakes: 1 message
status() drains to the end <-------------- HEADERS(END_STREAM: grpc-status=0 [, message, details-bin, trailers])
handler returns -> finish(OK)
returns the status code; oxl_grpc_call_response -> response bytes; oxl_grpc_call_freeOn an error: the handler calls oxl_grpc_call_add_detail(c, "type.googleapis.com/shop.OutOfStock", bytes, n) and oxl_grpc_call_finish(c, 9, "msg"); the
client gets code 9 from oxl_grpc_call_unary and reads oxl_grpc_call_detail_at(call, 0, &url, &bytes, &n). Transport outcomes map through
oxl_grpc_net_from_code.
Gaps (D-2550)#
HTTP/1.1 client/server for std.http (h2 only, via oxl_h2_request), gRPC compression, reflection, keepalive/idle/max-age, retries/hedging and load balancing,
interceptors, plain-HTTP routes on the server, HTTP/3.
Хранилище (stage 4, S1, [С §12.4, §12.8], D-4000..D-4099)#
Built-in storage of a node: our own LSM engine in runtime/src/store/ (no RocksDB/LevelDB), C ABI in runtime/include/oxl_store.h. Today it is a
single-node engine; Raft (one group, then multi-Raft by shard) will sit above it as the replication of a state machine whose storage this is.
DB (db.cpp, db_impl.h) callers: C ABI (capi.cpp) -> std.kv, actors, journals, coordinator
Write(batch, sync) ──► writer queue ──► leader: group commit ──► LogWriter ──► NNNNNN.log (WAL, fsync per group if any member wants it)
│ │
└─► MemTable (skiplist, MVCC) ◄── Get/Iterator (lock-free readers, snapshot = sequence number)
memtable full ─► switch: sync old log, new log, fsync dir ─► immutable memtable ─► background flush ─► NNNNNN.sst (L0)
background (Env::Schedule): flush > manual CompactRange > highest-score level ─► leveled compaction L0 ─► L1 ─► … ─► L6
VersionSet: immutable Version (files per level) + MANIFEST-N (edit log) + CURRENT (atomic pointer)
Env (env.h): PosixEnv | MemEnv (deterministic, crash + fault model) <- the only way the engine touches files, time, threadsLayers (each in its own file, each unit-tested):
| file | what |
|---|---|
util.h/.cpp | Status, varint/fixed coding, CRC32C (slicing-by-8), Hash64, seeded Random, Arena |
env.h, env_posix.cpp, env_mem.h/.cpp | file system, clock, background scheduling; POSIX and in-memory fault-injecting implementations |
format.h/.cpp | internal keys (user key ‖ seq<<8|type), WriteBatch, record log framing (WAL and manifest) |
memtable.h/.cpp | arena skiplist, one writer / many lock-free readers |
table.h/.cpp | SST: blocks, index, bloom filter, checksums, block cache |
iterator.h/.cpp | merging and level iterators |
version.h/.cpp | VersionEdit, Version, VersionSet (manifest + CURRENT) |
db.cpp, db_compact.cpp, db_impl.h | open/recovery, group commit, reads, snapshots, iterators, flush, leveled compaction |
capi.cpp | C ABI (oxl_store.h) incl. the simulated environment |
Consistency model (what the tests check)#
- Atomic batches: all operations of a
WriteBatchbecome visible together, are logged as one record and recover all-or-nothing. - Durability:
sync=true→ on disk when the call returns.sync=false→ a power loss may drop a suffix of the acknowledged writes, never a middle part (recovered state = a prefix of the write sequence). A write that returned an error may be applied or not (atomically), and the store turns read-only until reopened (the log may end in a torn record; appending behind it would hide later writes). - Snapshots/iterators see exactly the sequence number they were created at, independent of flushes and compactions; compaction keeps every version a live snapshot can see and drops tombstones only when nothing older can resurface.
- Corruption (bad CRC, malformed block, missing file) is reported as
Corruptionfor the affected read; checksummed SST data is never returned wrong.
Crash safety, concretely#
- A new file is
fsynced, then the directory isfsynced, only then is it referenced (new log before use, SST before its manifest edit, manifest beforeCURRENT). CURRENTchanges only bywrite tmp → fsync → rename → fsync dir; the previous manifest is deleted after that.- A memtable switch syncs the old log before creating the next one, so a torn record can only be at the end of the newest log with data.
- Recovery replays logs ≥
log_number, writes the recovered tail to L0 and starts a fresh log in the same manifest edit that retires the old ones; it can be interrupted at any point and re-run. - Files that a failed manifest update may reference are kept; everything unreferenced is swept at the next open.
Deterministic simulation (test sim, [С §18.4])#
All file, directory, clock and background-thread access goes through Env. MemEnv models power loss (unsynced file tails and unsynced directory operations
are partly lost, torn sectors), EIO on append/sync/read/directory operations, torn failed appends, disk full, a virtual clock, and runs background jobs inline
or from a queue (Pump()), so a simulator can interleave crashes between flush/compaction steps. Randomness is a seeded splitmix64. The C ABI exposes the same
knobs (oxl_store_env_*). tests/unit/runtime/store_model_test.cpp is the reference client: random operations against std::map with crashes and fault windows.
Tuning knobs (Options)#
write_buffer_size (4 MiB), block_size (4 KiB), bloom_bits_per_key (10), block_cache_bytes (8 MiB), target_file_size (2 MiB), level1_max_bytes (10 MiB, ×10
per level), level0_compaction_trigger (4) / level0_stop_trigger (12), max_group_commit_bytes (1 MiB), seed.
For the Raft/shard layers (not built yet)#
- One
DBper shard replica (or one per node with a shard prefix in the key); the applied Raft index goes into the sameWriteBatchas the applied command. - The Raft log itself is a different access pattern (append, truncate prefix/suffix): plan a dedicated log store on the same
Envrather than an LSM. - Snapshots for follower catch-up: iterate a
Snapshot(the engine has no checkpoint API yet, D-4099).
Gaps (D-4099)#
Reverse iteration, compression, range delete/TTL/merge, parallel background jobs, rate limiting, backup/checkpoint, mid-file corruption detection in the WAL/manifest
(the first bad record ends the log), prefix bloom, wiring Db.open("builtin://…") of std.orm onto the engine (D-4050).
Симуляция (stage 4, [С §18.4], D-4100..D-4110)#
Details and the protocol-author guide: docs/testing/simulation.md.
- Kernel
runtime/src/sim/:Worldis a single-threaded discrete-event simulator (virtual nanoseconds, events ordered by(time, seeded tie-break, seq)) with N nodes, aNetwork(latency/jitter, drop, duplicate, reorder, per-link overrides, symmetric / one-way / timed partitions), per-nodeDisks that survive crashes, kill/restart with incarnation numbers, clock skew, invariants, aTracewith a running hash (same seed -> same hash), aHistory+ register linearizability checker, and a seedsweeprunner. Protocol code (Raft, SWIM) is C++Processclasses using onlyNodeCtx(clock, rng, send, timers, disk). - Why a separate executor (D-4100): the OxL scheduler is M:N, work-stealing, with a timer thread, io_uring and preemption by
signal; making that deterministic means rewriting it. Cluster protocols are event-driven state machines anyway, so they run on the
kernel; OxL code (the test body) stays on the real scheduler and drives virtual time through
std.test. - Seam to the OxL runtime (
sim/session.h): whileOXL_TEST_SEEDis set (or afteroxl_sim_begin)oxl_time_now_nanos,oxl_instant_now_nanos,oxl_rand_u64andoxl_sleep_nanosuse the session world (clock + seeded RNG; sleep = advance virtual time). Everything else of the runtime is untouched when no session exists (one atomic load). - C ABI
runtime/include/oxl_sim.h:oxl_sim_*(explicit world: nodes with a callback vtable, faults, run, trace hash) andoxl_simt_*(the implicit session world, bound tostd/test/test.oxlby@intrinsic("rt:...")- no compiler changes). oxl test:simtests are runnable; the runner starts one process per seed (OXL_TEST_SEED), stops at the first failure, re-runs it withOXL_SIM_REPORT=1to confirm reproducibility and printsoxl test <pkg> --exact '<name>' --seed N.--seed N/--seeds a..bas in the Standard.- Storage:
Diskis the seam;SimDisk(own tiny file model) andMemEnvDisk(adapter over the LSM store's fault-injectingMemEnv).
К14: размещение и граф вызовов (stage 4, [С §12.1, §12.7, §13.4], D-4460..D-4479)#
Compiler-only part of the cluster (no cluster runtime needed). Code: compiler/src/sema/Placement.cpp, CallGraph.cpp,
runtime/src/cluster/callgraph.cpp.
- Declaration (
Sema::checkClusterDecl). Every clause ofcluster { … }is read intoClusterInfo/ClusterRole(include/oxl/sema/Service.h):seeds(static[…],k8s(label:),dns(…)),port,auth(jwt,oidc,apikey,session,roles from claim(…) as Enum),defaults,assign byand per roleserves,hosts,runs sagas/runs leader(n),journal,shards,replication,replicas,needs,expose … tls(cert:, key:)/tls(acme:). Unknown clauses and keys are errors. Two parser touches:seeds static[…](a keyword) is kept as the callstatic([…]), andreplicas 2..as a range whose end is an integer literal with suffixopen.assign by label("k")is rewritten toenv("OXL_LABEL_K")(D-4464). - Placement (
Sema::checkPlacement), all errors with their own page indocs/diagnostics:service-not-served,actor-not-hosted(sharded actors),topic-without-journal,saga-without-role(runs sagas+journal),unknown-role(on role,provide … for role, literalassign by),missing-provide(also for thedepof a sharded actor, per hosting role),placement-conflict(shards/replication without journal, replication abovereplicas, two values of a label, oneexposeport in two roles,tls(acme)+tls(cert)),cluster-seeds,cluster-needs,cluster-auth,cluster-decl. Sagas and pipelines are recorded (ServiceState::sagas/pipelines) for these checks and then lowered bySagaSema.cpp(see "Саги и пайплайны"). - Call graph (
Sema::buildCallGraph).FnSymbol::graphCallsrecords every call of a body, closures included (callSitesskips closures). For each role the builder walks from the code that runs there — handlers of served services, messages of hosted actors,providefactories,main(every node), the generated bodies of sagas (roles withruns sagas) and pipelines (the calls of a step withon roleonly for that role) — through plain functions and actor message bodies; a call of a service rpc becomes an edgerole -> pkg.Service.rpc, a messagerole -> pkg.Actor.msg. Conservative: no pruning byhasRole, interface calls and function values are not followed. A program withoutclusterhas the roleall. - Allow table. Codegen embeds the edges as
OxlCallEdge[]and callsoxl_callgraph_registerat node start (runtime/include/oxl_callgraph.h:oxl_callgraph_allows(role, target),_callers,_edge,_count). Enforcement (mTLS role -> table lookup) belongs to the policy agent.missing-policynow means: not allowed to external principals, not@internal, and called by no role (test builds also count calls from tests). oxl export --call-graph [path] [--out file]prints the roles, edges (with the calling functions), the per-role allow lists and, per rpc, its permissions and the roles that call it, as JSON (Sema::callGraphJson).- Routing hooks.
S.at(node)already type-checks and lowers toClient { kind 1, node }(rpc.at); the runtime answersUnavailablefor a foreign node until the coordinator exists.ActorRef<A>is an address in the wire schema (oxl.ActorRef,oxl/types.proto).
Raft (stage 4, wave 3 R, [С §12.4, §12.8], D-4300..D-4379)#
Our own Raft (customer decision: no NuRaft/etcd-raft; design ideas only). Code runtime/src/raft/ (namespace oxl::raft, part of liboxlrt),
tests tests/unit/raft/ (binary test_raft), the API contract is the header comment of runtime/src/raft/host.h.
K (std.kv, shard automaton) -> RaftGroup (propose / read_barrier / membership / leader info)
|
F (cluster transport, SWIM) <-> RaftHost (many groups, one tick, one log store, coalesced frames) -- HostEnv {self, send, defer}
|
RaftCore (pure: events in -> Ready out) LogStore (segmented files on store::Env)
LsmStateMachine (data + applied index in ONE WriteBatch)RaftCore(core.h/.cpp): one group, no clock, no I/O. Events:tick(),step(Message),propose,propose_conf_change,read_index,transfer_leader,campaign,compact,restore,report_snapshot. Effects:take_ready()returns aReady(hard state, entries to persist, snapshot to persist, committed entries to apply, messages, read states, snapshot requests); the host persists, fsyncs, sends, applies, thenadvance(rd). Leader appends/heartbeats (early_msgs) may leave before the fsync, everything else (votes, append acks) only after it. The same object runs under the simulation (NodeCtx) and in production.- Elections: pre-vote (a node that cannot win never raises the term), check-quorum (a leader that sees fewer than a majority within an election timeout steps down), leader lease for votes (a node that heard from a live leader recently ignores vote requests unless they carry the leader-transfer flag), randomised timeouts from a seed. Learners and unconfigured nodes never campaign or vote.
- Replication: per-follower
ProgressinProbe/Replicate/Snapshot. Appends are produced lazily attake_ready()so proposals of one turn share messages (batching); inReplicatethe leader pipelines with an inflight window (max_inflightmessages) and a byte limit per message; the uncommitted-bytes limit makesproposereturnBusy. Rejections carry a(hint_index, hint_term)so the leader skips whole conflicting terms. A new leader commits a no-op of its term first (entries of older terms are committed only through it - the figure-8 rule). - Membership (single-step, no joint consensus):
AddLearner-> catch up (PromoteVoteris refused withNotCaughtUpwhile the learner is more thanpromote_slackentries behind) ->PromoteVoter;Removefor anything. One change at a time, only after the leader committed an entry of its own term (the single-server-change fix). The configuration takes effect when the entry is appended and is recomputed from the log base on truncation. A group is bootstrapped by identicalAddVoterentries (index 1..N, term 1) on every initial member, so a later joiner replays the membership from the log. A removed leader steps down once the removal commits; the leader keeps nudging a removed member for a while, the coordinator destroys the replica anyway. - ReadIndex: reads are batched; one heartbeat round with the context of the newest read confirms leadership; the barrier completes when the state machine applied the commit index recorded at the time of the request. A new leader holds reads until its no-op committed. Reads are leader-only (no follower forwarding, no leases): customer answer 1.
- Snapshots: the core only asks (
snapshots_to_send) and later restores (restore(meta): log reset to the snapshot, commit = applied = index). The host streams chunks of aSnapshotReader(stop-and-wait,snapshot_chunk_bytes, resend aftersnapshot_ack_ticks, three tries) and installs them through aSnapshotWriter. Order on the follower: all chunks written ->restore-> the snapshot record is made durable ->finish()(marker cleared + applied index set in one batch) -> done-ack. A crash in between leaves the state machine "installing" (applied index reads 0) while the log is already compacted: at open the host detectsapplied < log baseand resets the replica's log (keeping term and vote), which makes the leader send a fresh snapshot. Compaction: afterapplied - base >= compact_thresholdthe host callssm.sync()(applied state durable) and then drops the prefix, keepingretain_entriesfor slow followers. - Quiescing: a leader whose group has been idle for
quiesce_tickswith every follower caught up sends a heartbeat with a quiesce flag; the followers stop their election timers. Wake-ups: any message but the quiesced heartbeat, a local proposal/read/membership op, a node that stops sending node beats (node_suspect_ticks) for the groups it leads, a peer whose beat carries a new incarnation (it rebooted). Node beats are one tiny frame per peer node pernode_beat_ticks, independent of the number of groups. LogStore(log_store.h/.cpp): ONE shared sequence of segment filesraft-<n>.logper node for all groups, written throughstore::Env(soMemEnv's crash model applies: unsynced tails vanish or get garbled, directory entries needSyncDir). Records:HardState,Entries("delete >= first.index, append"),Snapshot(reset),Compact,Create/Destroy(replica id, tombstone).commit(sync)writes everything staged by all groups with one append and one fsync (group commit across groups). Each new segment starts with a checkpoint (create + hard state + base of every group, tombstones) so old segments can be deleted once every group compacted past their entries - but only after the compaction record is durable; the previous segment is synced before a new one is created (no hole in the middle of the log). Recovery cuts a torn tail of the newest segment (atomic rewrite of the valid prefix), treats damage anywhere else as corruption, accepts a hole only if a Compact covers it. An I/O error fails the store for good (fail-stop; the host stops and the process restarts from disk).RaftHost/ multi-Raft (host.h/.cpp): groups keyed by id, deterministic (sorted) iteration. Onetick()drives every group;drive()collects the Ready of all groups, stages one log batch, one fsync, then sends, applies, advances. Outgoing messages are packed per destination node into frames ([version, kind, count, (group, len, message)*]), so the heartbeats of thousands of groups between two nodes share frames. A message for an unknown group creates it whengroup_factoryaccepts and the replica id is not tombstoned.destroy_groupdrops the log, writes a tombstone for(group, replica)and callson_destroy()of the state machine. Replica ids are unique per (group, node) incarnation: a node that leaves and comes back gets a new id.- Transport seam (
HostEnv):self(),send(Addr, frame)(best effort),defer(fn)(run after the current handler; batches proposals into one fsync). F plugs its channel in by implementing these three calls and callinghost.deliver(from, frame); under the simulation the same three calls go toNodeCtx. - API for K (
RaftGroup):propose(data, cb)(cb after commit AND apply on this node, with the state machine's reply; statusesOk,NotLeader(+hint),Superseded(definitely not applied),Timeout/Stopped(unknown),Busy,Removed),read_barrier(cb),leader(),membership(),add_learner,promote,remove,add_voter(the whole learner dance with retries),transfer_leader,campaign. Callbacks never run inside the call that registered them. - State machine on the LSM (
lsm_sm.h):LsmStateMachineapplies each entry with its effects and the applied index in oneWriteBatch(D-4000); snapshots are streams of key/value records of the group's prefix taken from an LSM snapshot; install marks the group "installing", deletes the old range key by key (noDeleteRangeyet), writes the chunks, then clears the mark and sets the applied index atomically. - Storage-agnostic state machine:
StateMachineknows nothing about the LSM; the SQLite-per-shard machine forbuiltin://tables (spec §12.8: Raft replicates row changesets) implements the same five calls (applied index in the same SQLite transaction, snapshot = consistent copy of the database file in chunks, atomic swap infinish()), D-4325. - Profiles:
Profile::prod()(tick 50 ms, heartbeat 150 ms, election 1-2 s, quiesce after 10 s),Profile::test()(tick 10 ms, heartbeat 50 ms, election 300-600 ms).
Tests (deterministic simulation from the first commit)#
tests/unit/raft/raft_core_test.cpp- the core on a synchronous message pump (through the codec): elections, pre-vote, check-quorum, conflicts, transfer, learners, removal, ReadIndex, compaction, quiescing, recovery.raft_log_test.cpp- codec round trips/garbage, log store: recovery, torn tails over many power-loss seeds, rolls, purge, tombstones, corruption.raft_sim_test.cppwithraft_harness.h/raft_scenario.h- 5RaftNodeprocesses (RaftHost +LsmStateMachineon aMemEnvDisk), a KV with put/get/cas, clients that record a history, a fault schedule drawn from the world's rng: partitions (isolate / split / one-way), loss, duplication, reordering, latency spikes, crashes and whole-cluster power loss with torn unsynced tails, disk write/sync failures, clock skew and drift, membership changes (add, remove, re-add under a new replica id), leader transfers, tiny snapshot thresholds (snapshots all the time). Invariants after EVERY event: election safety (<= 1 leader per group and term, ever), committed agreement (one (term, data) per committed index on all nodes), leader completeness, state machine safety (the same entry applied at the same index everywhere), applied <= commit <= last; periodic full log matching. At the end the cluster is healed, every node restarts, and every voter must have a leader, the same applied index and the same data; the client history must be linearizable (raft_kv_check.h: per-key search for put/get/cas with unknown-outcome operations).- Sweeps: four configurations x 60 seeds = 240 seeds in ctest (about one minute in Debug);
OXL_RAFT_SEEDS=NandOXL_RAFT_FIRST_SEED=Krun more. - Injected bugs (
RaftBugs.*): reads served without ReadIndex (caught by the linearizability check), log entries acknowledged before the fsync (caught as an election-safety violation after a power loss);test_break_commit_rule(commit of old-term entries by counting) is a hook for further experiments.
Gaps (D-4379)#
Follower reads and leader leases (1.0), joint consensus, forwarding of proposals/reads from followers, a bound on the memory of the in-memory log of a group with a very lagging member (only compaction bounds it), parallel snapshot streams per peer (stop-and-wait), per-group log files, asynchronous fsync (the host is synchronous), fsync latency in the simulation (kill points are between events), apply on a separate thread, coordinator-level cleanup of removed replicas (the core only nudges them).
Кластер: транспорт и членство (stage 4, F «ткань», [С §12.1–12.3, §13.1], D-4200..D-4259)#
Everything is in runtime/src/cluster/. A node of a cluster has one TLS port; on it ALPN h2 is the existing gRPC stack and oxl-cluster/1 the
cluster protocol (SWIM now; Raft, snapshots and saga takeover later register protocols on the same connections). SWIM runs over TCP inside that
channel (datagram frames), never over UDP.
:7000 (TLS 1.3, mTLS) process (OXL_SEEDS / --seeds enables it)
│ ALPN h2 ─────────────► grpc::Server::serveSession ─► services, rpc oxl_rt_start ─► cluster::autostart()
│ ALPN oxl-cluster/1 ──► Transport::handleInbound SIGTERM ─► LEFT gossip, then gRPC drain
│ │ HELLO / REJECT (cluster id, versions, node id == certificate SAN)
│ ▼
│ Mux ── streams (proto name, messages, credit flow control) ─► setStreamHandler("raft"|…)
│ │ └─ DGRAM (unreliable, bounded) ─► ClusterNode::onDatagram ─► Swim::onMessage
│ └─ PING keepalive, FaultInjector hook (delay / drop / blackhole per frame, per direction)
└ dial side: Transport::addPeer(addr) ─► backoff dial loop ─► same handshake ─► duplicate resolution (smaller dialer id wins)
ClusterNode: driver task ─► Swim::tick() / nextWakeup() ─► reconcilePeers() (members' addresses ─► addPeer/removePeer)
registry = Swim member table ─► cluster.nodes()/members()/selfInfo() (std), control socket (members, fault, partition, …)| Piece | Where | What |
|---|---|---|
| frames | frame.*, wire.h | 10-byte header (length, type, flags, stream), incremental decoder, max frame; varint/string codec for all cluster messages |
| mux | mux.*, mux_loop.cpp | reader / writer / keepalive tasks per connection (h2::Cond, so they work from tasks and plain threads), streams with whole-message send/recv, per-stream and per-connection credit (backpressure), datagrams, protocol-violation handling |
| handshake | handshake.* | versioned HELLO (protocol range, node id, cluster id, binary version, schema hash, advertised address, window parameters), typed refusals |
| transport | transport.* | listener + ALPN dispatch, dial loops with jittered backoff, duplicate resolution, sendDatagram, openStream, task / handshake tracking so stop() leaves nothing running |
| credentials | pki.*, enroll.* | files, dev CA (OXL_DEV_PKI_DIR), the built-in CA (OXL_CA_DIR / OXL_JOIN_TOKEN, see «Безопасность»), setCredentialProvider hook, SAN identity oxl://<cluster>/[role/<r>/]node/<id> |
| fault layer | fault.* | blackhole / delay / drop between node sets, asymmetric allowed; OXL_FAULT_*, control socket, API |
| SWIM | swim.*, swim_sim.h | pure state machine, runs on the real transport and on oxl::sim::World |
| node | node.* | ClusterNode (registry, driver, control socket), process-wide autostart() / shutdown() / membersText() |
| seeds | seeds.* | static[...], dns(...) resolved; k8s(...) parsed only |
oxl dev | tools/oxl/cmd_dev.cpp | N local processes on 127.0.0.1:7000+i, supervisor, status table |
SWIM (D-4205): probe every 500 ms (one shuffled round-robin target), 250 ms for the direct ack, then 3 indirect probes, SUSPECT after the interval, DEAD after
1.5 s without a refutation; test profile x3. Incarnation numbers (only the node raises its own), refutation, piggybacked gossip plus a 125 ms push of fresh
updates to 3 random members, suspect nudging, full-table push-pull every 5 s, seed JOIN, re-probing of dead members (heals partitions), LEFT for graceful leave.
Swim never reads a clock: tick() does everything that is due at Env::now(), nextWakeup() says when to call again. Detection on the 4-core container
(production profile, 5 nodes): first suspicion 0.6-1.2 s, all survivors DEAD 2.1-3.0 s after the failure (the Standard asks for 1-2 s for suspicion).
Registry (D-4206): every member publishes id, roles, zone, labels, address, binary version, schema hash and load; cluster.nodes() / cluster.members() /
cluster.selfInfo() return ClusterMember values (std/core/node.oxl, runtime ABI oxl_cluster_members_str). Outside a cluster the registry holds this node only.
Configuration (flag, then environment): --seeds/OXL_SEEDS (enables clustering), --listen/OXL_LISTEN (default 0.0.0.0:7000), --advertise,
--cluster-id/OXL_CLUSTER_ID, --zone/OXL_ZONE, --label k=v/OXL_LABELS, OXL_ROLE, OXL_NODE_ID, --data-dir/OXL_DATA_DIR, OXL_CONTROL_SOCK,
OXL_SWIM_PROFILE=test, OXL_BINARY_VERSION, OXL_SCHEMA_HASH, credentials OXL_CLUSTER_CERT|KEY|CA or OXL_DEV_PKI_DIR, faults OXL_FAULT_*.
The cluster { seeds ... port ... } declaration reaches the runtime (oxl_cluster_declare, D-4573) and a declared seeds list starts clustering by itself (D-4603).
Control socket (<data-dir>/control.sock, mode 0600, one line command per connection, reply ok\n... / error: ...): members, self, peers, stats,
events [n], fault <rule|list|clear>, partition a,b|c,d, heal, leave, label k=v, roles a,b. The real-cluster Jepsen tests drive faults through it.
oxl dev --nodes N [path] [--base-port 7000] [--roles a,b,c] [--zones x,y] [--data-dir d] [--swim-profile test] (D-4208): builds once, starts N nodes
(n0..), restarts crashed ones unless <data-dir>/n<i>/hold exists, prints the status table (including how many members each node sees alive), stops on
Ctrl-C/SIGTERM with a graceful leave.
Tests: cluster_mux_test (frames, handshake, streams, flow control, backpressure, violations, keepalive, fault layer), cluster_transport_test (real mTLS: dedup,
reconnect, identity binding, ALPN, partition/heal, 5-node mesh), cluster_node_test (members in one process, control socket, gRPC and cluster on the same port),
swim_test (virtual-time world, fixed seeds), swim_sim_test (the production state machine on oxl::sim::World), ctest cluster_dev5 (five real processes:
kill -9, restart, asymmetric fault, partition, heal, graceful stop), conformance §12.3/members-single-node.oxl.
Not done (see D-4259): plain-TCP dev mode, k8s seed resolution, Lifeguard-style dynamic suspicion, membership change subscriptions in std, passing the cluster
declaration to the runtime.
import proto и oxl gen (stage 4, wave 4 I, [С §16.3, §5.9, §12.8], D-4740..D-4799)#
Three pieces that connect OxL to other languages and to relational data; decisions in docs/decisions.md.
import proto "x.proto" { Names } (compiler/src/sema/ProtoImport.cpp, hook Sema::declareImportProto in FfiSema.cpp). There is no protoc
dependency: Lexer / Parser read proto3 into a small AST (File, Message, EnumDef, Service), Importer resolves type names with the protobuf
scoping rules, selects what the program asked for ({ A, B } plus everything those use) and renders OxL source (OXL_DUMP_FFI=1 prints it). The source
is parsed and declared in the importing module exactly like the output of import c. The generated declarations carry the information the
rest of the compiler needs to speak the foreign wire format with the existing machinery:
datafields have@tag(n)= the proto field number and default values (proto3 peers omit zero values; decoding a missing field without a default isDecodeError.Missing);- an rpc's parameters are the fields of the request message (
@tag(n) name: T), so the synthetic request message ofServiceSema.cppis byte-identical to the proto message; - a response message that is not the one-field envelope
{ result = 1 }is the value type with@proto_flat:finalizeServicescopies the fields of the value'sdatainto the synthetic response message, and since the layouts are equal the code ofCGService.cppthat reads / writes "field 0" needs no change; @proto_package("pkg")on the service makes the rpc path/pkg.Service/rpc;@proto_name("pkg.Msg")on an@errortype is the name used ingrpc-status-details-bin(wireNameOf);@serveon the import yields an implementableserviceinstead of anextern service.
oxl export --proto is the inverse and writes the options import proto reads ((oxl.error), (oxl.raises), (oxl.stream), idempotency_level) in oxl/options.proto.
Not imported: client / bidi streaming of a foreign .proto (the runtime's stream-in protocol sends a first message with the plain parameters; warning import-skip).
Tests: tests/unit/sema/proto_import_test.cpp, ctest proto_import_interop (foreign C++ server), proto_import_roundtrip (export, import, call the live OxL service, all four rpc kinds).
oxl gen --py|--ts (compiler/src/sema/GenClient.cpp, tools/oxl/cmd_gen.cpp). After Compilation::check() the Builder turns the pub(api) services of the
main package into a model: TD (type descriptors: scalar kinds with width / sign / zigzag, list, map, opt, union with member numbers in the
lockTypeName order, ref to a generated type, refined, time, ...), TypeD (data, fieldless enum, enum with fields, refined type with its where
expression) and ServiceD / RpcD (parameters with their wire numbers from ser::schemaOf(requestType), value, error types with their qualified names). PyPrinter
and TsPrinter print the model; the wire encoding lives in compiler/gen/oxl_runtime.py / .ts (embedded in the binary by compiler/gen/GenRuntime.cpp.in),
which encodes / decodes by the descriptors that the generated module registers (register_data, register_variants, ...). Refiner translates refinement
predicates (ranges, len(..), matches(re"…"), comparisons, &&, ||, !) to Python / JavaScript. Client stream and bidi calls send the plain parameters in a first message and each element in
its own, like the OxL client (D-3600). Tests: tests/unit/sema/gen_client_test.cpp, ctest gen_clients (tests/gen/): offline checks against protoc, then the generated clients call tests/services/pkg.
К7 relations and queries (std/orm/orm.oxl, compiler/src/sema/CheckTable.cpp, compiler/src/codegen/CGOrm.cpp, runtime/src/orm/orm.cpp). ref<T> is Ref<T> with
@repr(i64), so it is serialized as the key and orm.cpp classifies OXL_SK_REPR over a scalar as a column of that kind (CK::Repr, to_repr /
from_repr of the node convert). many<T> has no column (CGOrm leaves it out of cols; readRow gives such fields their default from the node). The table descriptor gets
rels (OxlOrmRel: field, kind, offset of the list inside the field, target descriptor, back-reference column). checkTableQuery lowers load(a, b) to .load("a").load("b")
on the Query<T> builder; Query.load records the names and oxl_orm_q_run runs one extra statement per name after the rows are read (loadRel: a semi-join on the
query's own WHERE / ORDER BY / LIMIT, parameters shared), then distributes cloned rows into the lists by integer key. group by over a table sends where / order by to SQL
and hands the fetched List<T> to the iterator query of §5.9 (listQueries_ adds .collect() so the result is a List). SQL dialects: the statements are the dialect-neutral text of D-3901, so the same tests run under --orm-backend postgres.
Хранилище таблиц и std.kv (stage 4, wave 3 K, [С §12.8, §11.2], D-4380..D-4459)#
Customer decision (STAGE4_PLAN 10): no own query engine. Tables of Db.open("builtin://…") are SQLite on every replica of a shard;
Raft replicates row changesets (SQLite session extension), never SQL text. std.kv and system data live in the LSM (S1). Code:
runtime/src/shard/ (liboxlrt), ORM routing in runtime/src/orm/orm.cpp, module std/kv/kv.oxl, tests tests/unit/runtime/shard_*.
ORM call (insert / update / find / from u in db.T …) std.kv (Kv.get / put / txn / watch)
| orm.cpp: routing by @shard key, fan-out + merge | kv_api.cpp: key -> shard ({tag}), OCC txn
v v
BuiltinStore (N shards) KvDb (one LSM, N KvShard)
SqlShard ── stage_commit ─▶ ReplicatedLog ◀── KvShard commands
| (LocalLog | RaftShardLog over RaftGroup)
v apply(index, cmd) on EVERY replica, leader included
SqlReplica: BEGIN; DDL…; changeset_apply; _oxl_meta.applied = index; COMMIT | KvShard: one WriteBatch (data + applied index)A write on a SQL shard (leader)#
begin_txn: wait for the shard's gate (one staging transaction per shard), check leadership, read barrier andcaught_up()(every entry of the log is applied),BEGIN IMMEDIATEon the leader's own database, attach a session to all tables. The handle lock is held: noapplyruns meanwhile.- The ORM executes its statements (inserts, updates, queries — they see their own writes). DDL (
CREATE/ALTER/DROP) is also noted in a list. commit_txn=stage_commit(changeset from the session,ROLLBACK, command = DDL list + changeset) +propose+ wait for the apply. The leader's database changes only when the command is applied, like every follower's: one code path, no divergence between "executed" and "replicated" state.last_insert_rowidis captured before the rollback.- Failure: leader changed (term differs,
NotLeader,Superseded) → nothing applied,Busy, the client retries;Timeout/Stopped→ outcome unknown, rely on idempotency records.
Apply (SqlReplica::apply, deterministic, atomic): skip if index <= applied; BEGIN IMMEDIATE; DDL statements; (after ALTER … ADD COLUMN the rows are
rewritten once, D-4385); changeset check (known tables, same column count) and sqlite3changeset_apply_v2(NOSAVEPOINT) with a conflict handler that aborts;
_oxl_meta.applied; COMMIT. Any failure is fatal (the replica stops; entries are never skipped). Snapshot = consistent copy of the database
(sqlite3_backup + sqlite3_serialize), install = deserialize + backup over the live database. state_hash() hashes schema + rows (not pages): tests compare replicas with it.
System tables on every replica: _oxl_meta, _oxl_idem (idempotency records of [С §11.2]) and _oxl_outbox (outbox rows); idem_get/idem_put/outbox_add
write them inside the staging transaction, i.e. in the same changeset as the row they belong to.
Shards and routing#
- A
builtin://namehandle is a group of oneConnper shard (orm.cpp,Conn::parts). 1 shard:<data-dir>/name.db(as before D-4380); N shards (?shards=N,$OXL_BUILTIN_SHARDS):name.shard<i>.db.BuiltinStore::set_shard_factorylets the cluster supply replicated shards. shard_of(key) = xxh64(key) % N. Row of a table with@shard(by: .f)→ shard of its fieldf; tables without it → shard 0.find/deleteby primary key route whenfis the key, else probe all shards. Queries (§5.9, lowered to SQL as before) run on the leader's SQLite: routed when the where clause fixes the shard key, otherwise on every shard withORDER BY+LIMIT skip+takeand an in-memory merge by the order expressions (fetched as extra columns), then skip/take;countsums.atomic(db)/db.transaction: a transaction is pinned to the shard of its first statement; touching another shard isCrossShardTx(DbErrorKind.CrossShardTx). Savepoints nest as before. Outside a transaction each statement is a write unit (one changeset).- Schema (
ensure) is created/extended in its own write unit before the statement that needs it (the session cannot describe rows of a table altered in the same transaction).
std.kv#
KvShard (LSM, keys s<id>/d/…, version || value, applied index in the same WriteBatch) applies batches of Put/Delete/Require(version): all preconditions
hold or nothing is written (CAS). kv_api.cpp adds the shard choice (hash of the key or of its first {tag}), linearizable reads (read barrier), an optimistic
txn (reads validated with Require, writes in one batch, body rerun on conflict), prefix scans (merged over shards) and watch (a bounded in-memory log of applied
commands per shard, merged by index, Lagged when overrun). Language side: std/kv/kv.oxl (rt: intrinsics, effect db).
Raft binding#
shard_common.h defines the seam: ShardSm (apply/snapshot/install/sync) and ReplicatedLog (propose/read_barrier/is_leader/term/caught_up, blocking). LocalLog
is the default single-replica log. raft_adapter.h: RaftShardSm (an oxl::raft::StateMachine over a ShardSm; empty/ConfChange entries apply as 'N') and
RaftShardLog (a RaftGroup behind the blocking seam; post runs closures on the host thread). Cluster wiring (a shard factory creating one Raft group per shard
and replica, shard map from the coordinator) is the job of agents F/C.
Tests#
tests/unit/runtime/shard_test.cpp (determinism, DDL, crashes, snapshots, leader change, conflicts, hooks, KV), shard_mem_group.h (N-replica fake log),
shard_sim_test.cpp (three real RaftHost nodes with SQLite state machines on oxl::sim: kill/restart, partitions, loss, reordering, snapshots; convergence and
"acknowledged writes are visible" after 25 seeds, OXL_SHARD_SEEDS=N for more), conformance §12.8/orm-builtin-*, §12.8/kv-*.
Gaps (D-4410)#
Cluster wiring, 2PC, follower reads, staging blocks apply on the same database, no compiler-supplied shard key for queries (SQL text pattern), builtin:// migrations,
KV range delete/TTL/durable watch history, no phantom protection in kv.txn.
Наблюдаемость (stage 4, agent O, [С §15.5], ТЗ Р7, D-5240..D-5259)#
Пользовательское руководство: docs/observability.md. Код: runtime/src/telemetry/, крючки в rpc/rpc.cpp, rpc/net.cpp, http1/http1.cpp, orm/orm.cpp, net/tls.cpp, obs/log.cpp; std/obs/obs.oxl.
rpc handler / http handler / orm / user span ──► SpanScope / SpanInfo ──► telemetry::submitSpan ──► bounded deque
│
counters (MState, Keyed, Registry) ──► collect() ──► renderPrometheus ──► GET /metrics (task) ▼
└─► encodeMetrics ──┐ exporter task (daemon): batch ──► grpc::Channel::unary
log.emit ── trace_id/span_id ──► encodeLogs ─────────────────────────┴──────► /opentelemetry.proto.collector.*.v1.*/ExportКонтекст трассы#
- Единственный носитель —
rpc::Ctxзадачи (rpcCtx, наследуется дочерними задачами):trace(32 hex),span(id текущего спана),parentSpan,sampled.ctx.traceId/ctx.spanIdчитаются из него. makeCtx— это и есть серверный спан rpc: идентификатор спана ставится всегда (один xorshift, единицы наносекунд), запись (SpanInfo.record) — только если трасса выбрана и задан экспортёр. Родитель — спан вызывающего (локальный вызов), либо удалённый родитель изtraceparent. Решение о выборке принимается один раз, у корня (sampleTrace(traceId, rate): хэш младших 64 бит идентификатора, поэтому все процессы решают одинаково), дальше наследуется (parent-based).SpanScope(rpc.h) — RAII-спан для всего остального (клиентский rpc, HTTP, ORM, пользовательские спаны, будущие шаги саг/пайплайнов): при активном спане подменяетrpcCtxзадачи копией с новымspan, при выходе возвращает прежний. Без экспортёра или при отброшенной трассе конструктор — одна проверка флага, ничего не выделяется. Крючок для шагов саг / пайплайнов / акторов / цепочек:rpc::SpanScope s("saga/step", telemetry::SpanKind::Internal); s.attr(...)(includerpc/rpc.h).- Сеть: gRPC — метаданные
traceparent(W3C, версия 00, флаг01выбрано) плюс прежнийx-oxl-trace;inboundOf(rpc/net.cpp) отдаёт значение вOxlRpcInbound.traceparent,applyCtxставит исходящее из текущего спана клиента. HTTP: клиент (oxl_http_do) добавляетtraceparent, сервер (serveRequestв std/http) читает его черезoxl_obs_http_begin.
Экспорт#
telemetry::start() (из oxl_rt_start, до cluster::autostart) читает флаги и OTEL_*; если задан адрес — запускает daemon-задачу, которая раз в OTEL_BSP_SCHEDULE_DELAY (или при заполнении пакета) забирает до batchSize спанов и шлёт TraceService/Export унарным вызовом grpc::Channel (тот же клиент h2c/TLS, что и у rpc, но мимо слоя rpc: экспортёр не порождает спанов). Очередь ограничена; при переполнении новый спан отбрасывается и считается (oxl_telemetry_spans_dropped_total). Протобуф — вручную (otlp_encode.cpp, поля opentelemetry-proto v1). telemetry::flush() в конце oxl_rt_start (после дренажа) отправляет остаток синхронно. Нет адреса — g_tracing остаётся false, экспортёр не запускается.
Метрики#
Три источника, один формат Family/Sample (collect()): реестр пользовательских метрик (metricHandle: mutex только при создании, дальше атомарные операции), коллекторы встроенных метрик (rpc: счётчики и LatencyHist в MState метода — без блокировок и поиска по картам на горячем пути; builtin.cpp: HTTP/ORM/TLS/планировщик) и статистика экспортёра. Выводы: Prometheus text 0.0.4 (GET /metrics на --metrics-listen, свой минимальный ответчик на net::Socket: один запрос — одно соединение) и OTLP-метрики (cumulative, по OTEL_METRICS_EXPORTER=otlp).
Стоимость#
Без экспортёра на пути внутрипроцессного rpc добавлено: newSpanId, чтение флага tracing(), два clock_gettime и три relaxed-инкремента счётчиков метода (см. D-5254 о цифрах).
Пробелы (D-5259)#
Спаны шагов саг/пайплайнов/акторов/цепочек и клиентских стримов не создаются (нет самих подсистем на момент написания / крючок есть); @trace(sample:) работает на rpc, а не на произвольной функции; tracestate не распространяется; статус ошибок ORM в спане/метрике не различается.
Координатор и маршрутизация (stage 4, wave 4 C, [С §12.1–12.4], D-4560..D-4659)#
The coordinator is Raft group 0 plus the machinery around it (runtime/src/coord/). It is the one place that decides who runs what
and where the data lives; everything else (SWIM, Raft, shard state machines, the router) is driven by or reads from it.
ClusterNode (SWIM, mTLS channel) coord::Coordinator (one thread "oxl-coord", event driven like RaftHost)
members, roles, labels -------------> reconcile every 300 ms:
stream "oxl-coord/1" (Raft frames, bootstrap -> controller (g0 leader) -> local reconcile -> drive moves -> hints
forwarded proposals, shard ops) <----> RaftHost: group 0 (SysSm) + one group per shard replica (RaftShardSm over SQL/KV)State (group 0, coord/sys_state.*)#
SysState is a deterministic machine over byte commands (no clocks; time stamps are inside the command): nodes (number #n, address, zone,
declared and effective roles, labels, served services, life alive/down/left, down-since), shards (group id, kind sql/kv, database, index/count,
replication factor, replicas (node, replica id, learner), leader hint, epoch, the move in flight), a system KV with versions and CAS (K/X/C/G
commands) for CA/KMS material and dynamic config, and the counters for replica ids, group ids and node numbers. It is durable in one file
(<data-dir>/coord/state/g0/sys.state, written by sync() before the Raft log is compacted, FNV checked). The system KV API for the security code
is coordnode::sysKvGet/Put/Cas/Del (coord/node_glue.h); reads go through the log, so they are linearizable.
Bootstrap (D-4561, D-4562)#
oxl cluster coord bootstrap(control commandcoord bootstrap, orOXL_BOOTSTRAP=1/--bootstrap) creates group 0 on that node at once (one voter).- Without it the smallest node id among the live SWIM members bootstraps when it sees
OXL_COORD_EXPECT(3) members or afterOXL_COORD_BOOTSTRAP_WAIT_MS; a node never bootstraps when some member advertises the SWIM labeloxl.g0=1(a group already exists). - The leader of group 0 then grows it: voters = the first 3 node ids (journal nodes preferred,
choose_group0_voters, replaced after the node-down grace), every other node becomes a learner (so every node has the map locally, no polling). A new node joins by being seen in SWIM. - Replica ids come from the replicated counter (never reused: a replaced node gets a new id, the old incarnation is destroyed).
The controller (group-0 leader, coordinator.cpp)#
Every 300 ms, when the leader has applied its whole log: mirror SWIM into the registry (J join / S down / left), assign_roles, size group 0,
place new shards (place_replicas: least loaded storage node, spread over zones; waits for min(replication, OXL_COORD_EXPECT) storage nodes or
placeWaitMs), plan_moves, plan_leader_transfer. All proposals of one round are serialized (next round when they are applied).
- Roles (
planner.cpp): explicitOXL_ROLE/label nodes are never touched and count towardsreplicas a..b. A node without roles asks for automatic assignment (assign by ... auto: labeloxl.auto=1, thenoxl_node_configurewaits up to 60 s forroles_assigned). Deficits are filled largest first by the eligible node (labels ofneeds) with the fewest roles, so with fewer nodes than role slots a node packs several roles; leftover nodes join the role with the lowestcount/minbelowmax. Sticky: assigned roles are never taken away. - Placement/rebalancing: replicas live on nodes with a
journalrole (every node when the declaration has none). Moves: replace a replica of a node that left at once, of a node down forNODE_DOWN_GRACE(60 s;OXL_NODE_DOWN_GRACE_MS, 10 s withOXL_COORD_PROFILE=test), restore the replication factor of shards placed on too few nodes, then balance whenmax - min >= 2replicas (hysteresis). At most 2 moves in the cluster at a time. A move whose new node vanished is aborted after the grace. - Leaders: group leaders report
Lhints; when one node leads at least 2 more shards than another the controller asks the leader node totransfer_leaderto a replica on the light node (cooldown 5 s, never during a move).
A move (never below quorum)#
M(group, add, remove) records a learner replica in the map (the shard has repl+1 replicas from now on). The leader of that shard
(each node watches the map for the groups it leads, drive_moves) runs add_voter (learner → catch up, snapshot if needed → promote), then
remove(old), then proposes F (done) which drops the old replica from the map. The old node destroys its replica (on_group_removed, data deleted
by the state machine's on_destroy; a left-over replica of an earlier incarnation is destroyed when the map names a new one for the node). Every step
is idempotent, so a new leader simply continues; an interrupted removal is healed by "members the map does not know are removed". Calls during a move
wait: ClusterKv::call retries "not leader / superseded" answers for OXL_REBALANCE_BUFFER_MS (15 s) — this is the REBALANCE_BUFFER; a write whose
outcome is unknown (timeout) is NOT retried.
Shard state machines in a process (coord/node_glue.*)#
KV shards share one LSM per node (coord/kv.lsm, prefix s<group>), their sync() is a WAL fsync (the Raft log is compacted only after it);
SQL shards are one SQLite file per replica (coord/sql/<db>.s<i>.r<replica>.db). std.kv in a cluster (kv_api.cpp → ClusterKv) asks the
coordinator for the database (idempotent D command), then sends P (propose), G (get), S (scan) to the leader of the shard — locally or
over the coordinator stream ("shard ops": the leader runs them on its coordinator thread, reads after a ReadIndex barrier). builtin:// tables use
BuiltinStore::set_shard_factory: a node with a replica of the shard uses it while it leads the shard; a node without a replica, or with a follower,
runs the statements on the shard leader (remote SQL, shard/sql_remote.*, D-4602): the leader keeps a staging transaction per client session (ops B begin, X exec,
Y query with the rows returned materialised, C commit, R rollback, forwarded as shard ops to the leader), reads outside a transaction run under a ReadIndex barrier; a
session idle for 30 s is rolled back. A transaction cut by a leader change fails with the retryable busy error. Forwarded shard requests are resent every 700 ms until the
answer arrives and the receiver deduplicates them (one execution per request id, cached answer), because the node channel may lose a frame. A Raft apply (also the no-op
and conf-change entries) preempts a staging transaction that holds the replica instead of waiting for it (the thread that would commit it is the apply thread, D-4606).
Shard counts: OXL_SHARDS (the acceptance/Jepsen profile uses 16–32), else the shards n of the journal role, else what the program asked for.
Routing Х2 (rpc/route.cpp)#
oxl_rpc_route keeps the in-process path (no serialization) when this node serves the service. Otherwise kind 0 calls pick a node: live members whose roles
serve the service (service roles from the generated table), same zone first, then the lowest EWMA latency + 5 * in-flight + load, round-robin on ties;
a per-node circuit breaker opens after 3 consecutive UNAVAILABLE/timeouts for 2 s and then admits one half-open probe. @readonly/@idempotent rpcs are
retried once on another node on UNAVAILABLE. S.at(node) (kind 1) goes to that node (UNAVAILABLE when it is down or does not serve the service). The call is a
gRPC unary call over an mTLS h2 session on the shared cluster port (ClusterNode::h2ClientTls).
Language level (std/core/node.oxl): cluster.find(service), findRole, pick(service, by:, prefer:), on(.RoleChanged | .MemberJoined | .MemberLeft) { },
cluster.hasRole, cluster.ids.next(), cluster.leader(name) { }.
cluster.leader (coord/leader.cpp)#
A lease leader/<name> in the system KV (node|deadline_ms, TTL 3 s, renewed every 400 ms by CAS). The holder runs the block; a node that cannot renew
cancels its leader task before the lease expires; the others wait in oxl_cluster_leader_wait. Roles that declare runs leader("name") restrict the candidates.
Tests#
tests/cluster/test_coord_cluster.py(ctestcluster_coord, 5 real processes): bootstrap, routing edge -> store, kill -9 of a shard leader, dead node replaced, partition,builtin://tables incl. a transaction used from the replica-less edge node,watchon the clustered std.kv.tests/cluster/smoke_256.py: 256 shards (D-4605).tests/unit/coord/coord_planner_test.cpp: state machine, snapshot, declaration text, roles (labels, packing), placement, move caps/hysteresis, leader balance.tests/unit/coord/coord_sim_test.cpp(test_coord, seedsOXL_COORD_SEEDS): bootstrap with staggered starts, placement, kill/restart of the group-0 leader and of a shard leader, dead node replaced after the grace, new nodes take their share, graceful leave, partition, auto roles. Invariants every 50 ms: ≤ 2 moves, never fewer replicas/voters than placed, distinct nodes, plus final convergence, acknowledged writes present, group 0 identical everywhere.tests/cluster/test_coord_cluster.py(cluster_coord): the same onoxl dev --nodes 5with grpcurl, kill -9 and partition.
Безопасность (stage 4, W4-S, [С §13.1–13.3], D-4660..D-4739)#
Криптография — OpenSSL (AWS-LC — долг к 1.0). Код без зависимостей от рантайма — runtime/oxlsec/ (OBJECT-библиотека: её объекты лежат и в liboxlrt.a, и в oxl, поэтому oxl cluster init / oxl keys rotate работают без запущенного узла). Всё, что нужно рантайму, — runtime/src/cluster/enroll.*, runtime/src/rpc/auth.*, runtime/src/crypto/field_kms.*.
oxl cluster init ─► <ca dir>/{cluster.id, ca.pem, ca.key.enc, token.key.enc, tokens/, deny} master.key (0600, OXL_KMS_KEY_FILE)
│ CaStore (seam: coordinator Raft group later) │ wraps CA key, token key, data keys
custodian node (OXL_CA_DIR) ◄────┴── ALPN oxl-enroll/1 on the cluster port ◄── joiner (OXL_JOIN_TOKEN): CSR + token ──► cert, SAN oxl://<c>/role/<r>/node/<id>
│ renew at 2/3 of 24 h (Transport::setTls, connections untouched) · signed deny-list (op=crl) · identity cache <data-dir>/identity
▼
mTLS everywhere ─► checkIdentity (SAN == HELLO node id, deny-list, production: SAN required) ─► h2 peer identity ─► x-oxl-via
client ─ authorization: Bearer <jwt> ─► authorizeInbound ─► x-oxl-principal ─► ctx.caller / ctx.via / ctx.userId()| Часть | Где | Что |
|---|---|---|
| KMS | oxlsec/kms.* | мастер-ключ в файле 0600 (OXLKMS1 <версия> <base64>), обёртка AES-256-GCM с AAD, ключи данных <keys dir>/<имя>.key, Kms::rotate (старая+новая версия в файле на время перешифровки) |
| CA | oxlsec/ca.* | EC P-256, сертификаты 24 ч, signCsr (только публичный ключ), токены входа oxlj1.… (HMAC, TTL, счётчик под блокировкой хранилища, роль, отзыв), подписанный deny-list |
| Приём узлов | cluster/enroll.*, Transport::setEnrollHandler/setTls, ClusterNode::securityLoop | CaService::serve, enrollJoin/Renew, fetchDenyList, кэш удостоверения, ротация и обновление deny-list |
@encrypted | CheckTable.cpp, CGOrm.cpp, orm/orm.cpp, crypto/field_kms.* | колонка str/bytes → BLOB 0x01|nonce|шифртекст|тег, ключ orm/<таблица>/<колонка>; kms::encrypt/decrypt — API для топиков и акторов |
| Аутентификация | oxlsec/jwt.*, oxlsec/auth.*, rpc/auth.* | RS256/ES256/EdDSA, JWKS (файл/URL/PEM), kid, обновление при неизвестном kid; authorizeInbound до обработчика; cluster { auth { jwt(...) } } → oxl_auth_configure |
| std | std/core/rpc.oxl, std/rpc/rpc.oxl | Principal (User с ролями/claims/mfa/tenant, Service, Node, Anonymous), Ctx.via, asScoped, asService |
Доверенные метаданные. x-oxl-principal (JSON вызывающего), x-oxl-via (вызывающий сервис), x-oxl-user ставит только рантайм. У недоверенного клиента все три вырезаются, authorization не пробрасывается дальше никогда. Доверенный — узел кластера с проверенным клиентским сертификатом и oxl://-SAN: он может передавать их дальше (делегирование), x-oxl-via = Service(имя = id узла, роль = первая роль сертификата). Узел, вызывающий другой узел по gRPC, обязан предъявить сертификат (cluster::makeH2ClientContext) — иначе вызываемый не поверит делегированному принципалу. Сокращение стадии 3 x-oxl-user работает только в профиле dev без настроенной аутентификации.
Конфигурация. Узел-хранитель CA: OXL_CA_DIR (+ OXL_KMS_KEY_FILE); присоединяющийся: OXL_JOIN_TOKEN + семена (OXL_SEEDS) или OXL_ENROLL_ADDR, OXL_DATA_DIR для кэша удостоверения. OXL_CERT_TTL_SEC (тесты), OXL_CRL_REFRESH_SEC (15), OXL_ENROLL_TIMEOUT_SEC (60). Аутентификация: OXL_AUTH_JWKS, OXL_AUTH_JWT_KEY, OXL_AUTH_JWT_ISSUER, OXL_AUTH_JWT_AUDIENCE, OXL_AUTH_ROLES_CLAIM, OXL_AUTH_JWKS_MIN_REFRESH_SEC. Профиль: --profile production / OXL_PROFILE=production — сертификат из файлов без oxl:// SAN отвергается, OXL_DEV_PKI_DIR и --insecure-dev запрещены; --insecure-dev вне production принимается, но по-прежнему mTLS с автоматическим dev-CA (D-4670).
CLI. oxl cluster init|token create|token list|token revoke|node remove|node allow|status, oxl keys rotate, oxl dev --nodes N --builtin-ca.
Отказы. Нет учётных данных — Anonymous (решают политики); недействительные — UNAUTHENTICATED до входа в обработчик; обработчик, возвращающий AccessDenied, — прикладная ошибка (D-4679). Токен входа: единый текст отказа для пира (причина — в журнале CA).
Тесты. test_security (21), ClusterEnroll.* в test_runtime, ctest security_cluster_ca (3 процесса, ротация сертификатов 14 с, oxl keys rotate, удаление узла, oxl dev --builtin-ca), security_jwt_grpcurl (grpcurl, JWKS по URL с ротацией ключей), conformance §13.2, §13.3, fuzz fuzz_jwt.
Не сделано (D-4739): одиночный хранитель CA без failover (перенос CaStore в группу координатора — задача агента C), приватный ключ присоединившегося узла лежит открытым в каталоге данных (0600), нет внешнего kms(url), @encrypted для топиков и акторов — только API, oidc/apikey/session — 1.0, политики (Pol, волна 5), AWS-LC.
Политики (stage 4, wave 5 Pol, [С §13.4], D-5010..D-5069)#
policy-блоки, @public, @allow(role:), @internal компилируются в код (без интерпретатора), проверка идёт в рантайме при каждом входе в обработчик; права на данные (@owner, @visible) применяются в ORM и при кодировании ответа; решения @audit пишутся в подключаемый приёмник.
policy Svc { allow get(id) if caller.isUser(db.find(Order, id)?.userId) deny delete unless … }
│ parser: условие правила разобрано заново для каждой цели (PolicyRule::conds) — дерево переезжает в функцию
▼ sema/PolicySema.cpp buildPolicies() (в конце checkServices, когда известны @public/@internal)
RpcInfo::policyMode 0 нет правил │ 1 allow (константа) │ 2 deny (константа) │ 3 функция policyFn
RpcInfo::policyFn fn policy#Svc.rpc(<параметры rpc>) -> bool { let ctx; let caller; let <dep db>;
if <deny> { return false } … <allow> || <allow> } ← обычная функция: проверка типов, эффекты, dep
▼ codegen/CGService.cpp tramp (внутри задачи обработчика: ctx и принципал известны)
dec = oxl_policy_gate(desc, mode, "pkg.Svc.rpc") → 0 выполнить │ 1 AccessDenied │ 2 вызвать policyFn
отказ: результат = член NetError `AccessDenied` → wire: outcome 4 / gRPC PERMISSION_DENIED (UNAUTHENTICATED — только плохие учётные данные)
▼ runtime/src/rpc/policy.cpp (контракт — runtime/include/oxl_policy.h)Кто как судится (gate). Метаданные ctx — доверенные ключи x-oxl-principal, x-oxl-via, x-oxl-user и x-oxl-origin (ставится authorizeInbound на каждом входящем вызове, клиентский стирается):
| Вызов | Решает | Условия пользователя |
|---|---|---|
код программы без принципала (main, фон, тест) | доверен | — |
x-oxl-via (узел кластера, SAN сертификата) или принципал Service/Node (asService) | таблица oxl_callgraph_allows(роль, "pkg.Svc.rpc") для каждой роли узла (role — через запятую); нет таблицы = отказ | не вычисляются (q.8) |
вызов из обработчика этого процесса (Ctx::nested), если какая-то активная роль вызывает цель | граф | правило с условиями (mode 3) вычисляется для делегированного пользователя; константы 1/2 не мешают (@internal доступен коду программы) |
внешний принципал (User, Anonymous, в т. ч. asScoped(...) из main) | политика: mode 1 допуск, 2 отказ, 3 функция | да |
Без правил (mode 0) rpc закрыт для внешних принципалов — но только если программа вообще использует авторизацию (Sema::policyEnforced(): есть policy, @public/@internal/@allow, auth { … } кластера или @owner/@visible); иначе (программа этапа ≤ 3) всё открыто, как раньше (D-5012). Для rpc без условий решение — константа дескриптора: вызов в процессе из main = вызов oxl_policy_gate и просмотр пустых метаданных. Потоковые rpc: тот же вход; отказ → oxl_policy_stream_denied, рантайм отвечает PERMISSION_DENIED.
Роли. caller.has(role: .Support): has<R: Show>(role: R) в прелюдии; CheckCall.cpp унифицирует R с перечислением из roles from claim(...) as AppRole (ClusterInfo::authRolesType), так что .Support проверяется как вариант AppRole, а рантайм сравнивает имя варианта со списком ролей токена. caller.service("billing") = isService. Поле User.roles остаётся List<str> (D-5011).
Данные. @owner(.f, bypass: [...]): дескриптор таблицы (OxlOrmTable::owner/owner_bypass) → orm.cpp: find добавляет AND f=?2, запросы (oxl_orm_q_run/count) — AND (f = ?N) до маршрутизации по шардам (если f — ключ шарда, запрос идёт на один шард), update/delete — то же условие; запись чужой строки — ошибка; анонимный внешний вызывающий не видит строк. Владелец вычисляется на задаче вызывающего (blocking выполняется в другом потоке). @visible(role: .R): OxlSerField::visible_role; кодировщики (bin/json) пропускают поле между oxl_ser_mask_begin(inbound) и oxl_ser_mask_end — только при кодировании ответа rpc (wire и элементы потока), хранение и вызовы в процессе не затронуты.
Аудит. @audit на rpc или policy: OxlAuditRecord { unix_ns, trace_id, principal, via, action, target, allowed, reason } → oxl_audit_emit → приёмник (oxl_audit_set_sink). Приёмник по умолчанию: строка JSON в <OXL_DATA_DIR>/audit.jsonl (иначе ./.oxl/) и запись лога info; агент топиков подставит приёмник, публикующий в системный топик oxl.audit (@retain 30d). oxl audit tail [--data-dir D] [-n N] [-f] [--json] читает локальный файл.
Крючки для остальных объектов политики (актор — по msg, топик — publish/subscribe, динамический конфиг — set): oxl_policy_gate_object(kind, target, mode, audit) / oxl_policy_done_object — те же классификация и аудит; sema принимает policy на актор/топик/конфиг (имена проверяются), но кода для них ещё не строит — это делают агенты акторов, топиков и конфигурации (D-5020).
Тесты. conformance §13.4/policy-*, data-rights-* (stage 4), test_runtime Policy.* (6: ворота, граф, аудит, @owner, @visible), ctest policy_grpcurl (JWT-пользователи по gRPC: deny-by-default, @public, @allow, условия с параметрами/ctx/чтением db, deny … unless, @internal, @owner на find/query/update/delete и bypass, @visible, @audit + oxl audit tail).
Топики и outbox (stage 4, wave 5 T, [С §10.5, §11.2, §11.4], D-4900..D-4999)#
Топик — журнал с партициями поверх std.kv (D-4402): никакого отдельного хранилища, один путь записи для одного узла и для кластера.
topic OrderEvents: OrderEvent sharded by .userId (TopicSema.cpp: тип X__T + static X)
X.publish / emit / emitIn / subscribe / subscribeKey / count / sweep std/topic/topic.oxl
│ rt: oxl_topic_* runtime/src/topic/topic_api.cpp
▼
TopicStore (topic_store.cpp) ── Backend ──► KvDb (локально: KvShard+LocalLog; кластер: ClusterKv → Raft-группы шардов)Раскладка ключей (хеш-тег {топик#партиция} держит всё про партицию на одном шарде, поэтому одна Raft-запись меняет сообщения,
курсоры и аренды вместе):
| ключ | значение |
|---|---|
m/{t#p}/<20 цифр> | сообщение: ts_ms, ключ, полезная нагрузка (std.ser) |
~tail/m/{t#p}/ | счётчик смещений (ведёт шард: операция Append) |
d/{t#p}/<id> | запись дедупликации публикации (id outbox), живёт 24 ч |
gc/{t#p}/<группа> | курсор группы: всё ниже подтверждено |
gl/{t#p}/<группа>/<смещение> | аренда элемента (владелец, срок) или «подтверждён вперёд» |
i/{ключ} | запись идемпотентности rpc (состояние, срок, хеш тела, ответ) |
meta/{t} | число партиций (первое объявление побеждает) |
Операции KvShard: Append (шард назначает следующее смещение, ответ несёт смещения) и DeleteRange (retention). Смещения без дыр и
монотонны на партицию; счётчик переживает удаление.
Публикация. partition = xxh64(ключ) % N; без sharded by — одна партиция. Порядок гарантирован на ключ. Retry (нет лидера)
повторяется внутри, Unknown (исход неизвестен) возвращается как TopicError.Unknown.
Подписка. Без group — широковещательная: позиция в памяти, from задаёт старт, партиции читаются по кругу. С group — очередь:
claim берёт первый элемент без живой аренды CAS-пакетом, ack (при запросе следующего элемента циклом for await) двигает курсор;
не подтверждённый элемент возвращается после срока аренды (по умолчанию 30 с). Доставка at-least-once.
Outbox. atomic(db) { …; emit T(x) } = db.transaction(() => { … T.emitIn(db, x) }); emitIn кладёт строку в _oxl_outbox шарда
транзакции в том же наборе изменений, что и строки (D-4391); откат транзакции убирает и событие. Ретранслятор (outbox_relay.cpp) на
лидере шарда читает закоммиченные строки по порядку, публикует с id o:<бд>:<шард>:<id строки> и удаляет. Повтор после сбоя поглощается
записью дедупликации. Идентификаторы строк не повторяются (D-4902).
Retention. Фоновая нить каждого узла (OXL_TOPIC_SWEEP_MS) удаляет диапазоны по возрасту и числу; курсоры групп перескакивают
вперёд. Аудит (oxl.audit, 30 дней) — очередь в памяти и фоновая публикация из oxl_audit_emit. Политики — oxl_policy_gate_object
(topic.publish/topic.subscribe), рёбра графа вызовов topic:<имя>.publish|subscribe.
Тесты: topic_test.cpp (юнит + симуляция отказов на виртуальных часах), topic_sim_test.cpp (3 реплики Raft на oxl::sim),
tests/cluster/test_topic_cluster.py (5 процессов, kill -9 лидера), conformance §10.5/topic-*.
Шардированные акторы и Х6 (stage 4, wave 5 A, [С §12.5–12.6, §17.3], D-4800..D-4899)#
Sharded actors#
actor X sharded by key: T — ключ (поле 0 состояния) плюс var. Код:
| file | role |
|---|---|
compiler/src/sema/ShardedActors.cpp, TypeDecls.cpp, CheckCall.cpp | key field, hooks as methods, X(key), X.local(key), R | NetError + net, serializability of key / vars / messages |
compiler/src/codegen/CGActor.cpp | OxlActorType per actor (init, encode/decode of the persisted tuple, invoke per message), start-up registration, call site |
runtime/include/oxl_actor.h | C ABI between the two |
runtime/src/actor/engine.* | activations, mailbox, journal, dedup, passivation (no threads, no cluster) |
runtime/src/actor/sactor_rt.cpp | stores (local LSM / cluster shards), routing, sweeper, server side of remote messages |
Path of Account(7).deposit(5):
- call site: encode the argument tuple,
oxl_sactor_call(ref, msg, bytes)creates a random 16-byte message id; - runtime: shard = hash of the tag
{type\0key}over the shards of_oxl_actors; if this node leads the shard group, the engine runs the message here, otherwise the message goes as shard operationAto the leader (resent with the same id on any doubt, up to 30 s); - engine (per actor, one message at a time): activation = read the state record (
OXA1: ring of recent message ids, vars) andon activate; a known id is answered from the journal; elseinvoke, then ONE batchRequire(state, version) + Put(state) + Put(marker) + Delete(old markers); the reply leaves after the commit; - a changed owner (failover, move): the next message activates the actor from the journal on the new leader; the old activation fails the version
Require(zombie) or is dropped by the sweeper (evictUnowned).
edge n0 shard leader n1 followers n2, n3
Gate.add ─ shard op A ─▶ engine.deliver ─ Raft batch ─────▶ replicate, commit
│ activation (memory) ◀──────── commit index
◀──── reply (after commit) ┘What the tests check (D-4830): unit test_actor (fake store, 120 random schedules: kills, lost answers, rival owners), conformance §12.5, ctest cluster_actors (kill -9 of the owner while sending: ~5 s to resume, nothing lost or doubled; partition of the new owner).
Limits: a zombie can run a message body once before it loses the version race (side effects), the dedup window is the last 64 messages, policy modes for actor messages are not generated yet (D-4817), @encrypted seals the whole record (D-4816).
Х6 (runtime/src/coord/x6.*, std/core/node.oxl)#
cluster.now() -> Hlc:(ms << 16 | counter, node), strictly increasing, merged from every message of the coordinator channel (Hprefix) and every rpc (x-oxl-hlc); a stamp more thanMAX_DRIFT(500 ms) ahead is refused (cluster.observe).cluster.ids.next(): snowflake (41 ms / 10 node / 12 counter).cluster.lock(key, ttl) { }/lockFenced: lease in the coordinator's system KV; the fencing token is the KV version of the taking CAS; renewal thread;Fence.valid().cluster.shards(): the coordinator's shard table (info only).cluster.leaderis C's (D-4585, unchanged).
Саги и пайплайны (stage 4 wave 6 Sg, [С §10.6, §11.3], D-5100..D-5189)#
Код: compiler/src/sema/SagaSema.cpp (генерация, анализатор), std/saga/saga.oxl (журнал, исполнитель, восстановление), runtime/src/rpc/rpc.cpp
(локальная идемпотентность по ключу), runtime/src/sched/task.cpp (фоновые задачи без дедлайна вызывающего).
Лоуеринг. Как топики (TopicSema.cpp), saga и pipeline превращаются в обычные функции генерацией OxL-текста и его разбором в том же модуле; тексты шагов
копируются дословно, поэтому проверяются как шаги цепочки (§8.4), а диагностики сгенерированного кода переносятся на исходные символы (remapSagaDiagnostics).
Типы результатов выводятся (FnDecl::inferReturn — тот же механизм, что у приватных fn … => e, но для любой формы тела): тип вызова — тип цепочки, т.е.
R | E… | CompensationFailed | NetError. Параметры идут в журнал как значение сгенерированного Args__name (std.ser), поэтому обязаны быть Serializable.
saga placeOrder(snap) -> Order { A undo r => U1 -> B undo p => U2 -> C }
fn placeOrder__body(snap) => await (() -> std.saga.enter(0, it) -> y0 => (y0 -> A retry 6)
-> std.saga.guard(0, it, r => U1, x0 => await (std.saga.done(0, false, x0)
-> std.saga.enter(1, it) -> y1 => (y1 -> B retry 6) -> std.saga.guard(1, it, p => U2, x1 => await (done(1,false,x1)
-> enter(2, it) -> y2 => (y2 -> C retry 6) -> done(2, true, it))))))
fn placeOrder__run(a: bytes) => placeOrder__body(placeOrder__dec(a).snap) // то, что исполняет исполнитель
pub fn placeOrder(snap) => std.saga.drive("pkg.placeOrder", encode(&Args__placeOrder{ snap })!, a => placeOrder__run(a))
fn boot__sagas() { std.saga.serve("pkg.placeOrder", std.saga.sagaRoles(), a => placeOrder__run(a)) } // зовёт codegen при старте узлаКомпенсации — это вложенность: guard(k, v, comp, rest) исполняет остаток саги и, если он вернул ошибку, comp(v) шага k; поэтому они идут в обратном
порядке сами. Это значение «undo x => …, где x — результат шага» без явного стека замыканий (его негде хранить при переезде на другой узел).
Журнал (std.kv, хранилище oxl.saga; все ключи саги несут тег {sagaId} и лежат в одном шарде, транзакция читает и пишет их вместе):
s/{id}/h голова (имя, закодированные аргументы, пользователь), s/{id}/o аренда исполнителя {node, expiry, epoch}, s/{id}/t состояние (running,
compensating, stucking, затем done|failed|cancelled|stuck, курсор шагов, результат Boxed<T>), s/{id}/c маркер отмены, s/{id}/uK «компенсация шага K сделана»,
a/<сага>/{id} — индекс незавершённых, x/{id} — stuck. Запись состояния — транзакция, проверяющая epoch аренды (устаревший исполнитель ничего не меняет).
Кто исполняет. drive (вызывающий, любой узел) создаёт голову и индекс и ждёт конечного состояния опросом журнала (4..80 мс); сам сагу он не исполняет.
Исполняет цикл восстановления serve на узлах ролей runs sagas (saga.roles — константа из cluster): он просыпается по kv.watch индекса (и раз в 300 мс),
берёт аренду (claim: нет аренды, или она истекла, или SWIM пометил владельца dead/left), запускает execute в отдельной фоновой задаче и продлевает аренду (renew, каждую треть срока,
срок 3 с). Захват после падения заново выполняет тело саги с первого шага: шаг каждый раз получает idempotency-key = sagaId#N (компенсации — sagaId#uN), а rpc шагов
@idempotent (анализатор требует), поэтому повтор отвечает первым результатом (кластерная запись T2 на проводе, запись узла для локальных вызовов — rpc.cpp). Результаты шагов в журнал не пишутся.
Дедлайн (ответ 9). drive оборачивает ожидание в defer { shield { … } }: если задачу отменили (within, дедлайн rpc), одной транзакцией пишется маркер отмены (если сага ещё
не «закоммичена») и вызывающий сразу получает Timeout. Исполнитель проверяет маркер в enter перед каждым шагом; последний done(k, true, …) — точка коммита, проверка маркера и
запись commit в одной транзакции с маркером отмены: сага либо закоммичена, либо отменена. После маркера guard-ы компенсируют завершённые шаги в фоне.
Компенсации. До SAGA_UNDO_RETRIES = 5 попыток, пауза 100 мс · 2^n, не более 5 с; потом состояние stuck, сага возвращает CompensationFailed{cause, step}, внешние guard-ы не
компенсируют дальше. std.saga.status/resolve/list(.Stuck).
Пайплайны. P__front — шаги до respond включительно (значение — ответ; ошибка — ответ-ошибка), шаги после respond — respondThen(v, tail):
хвост уходит в фоновый scope узла (node.background.spawn, без дедлайна вызывающего, D-5107) с !> на on_error(e, step) и DLQ (std.saga.dlq/dlqList). С первого durable-шага
у пайплайна есть запись в журнале (те же ключи): перед каждым надёжным шагом пишется значение, входящее в шаг; после падения узла цикл servePipe захватывает запись и
вызывает P__resumeJ, которая декодирует значение (тип берётся из никогда не вызываемой цепочки-свидетеля, valueOf) и продолжает с шага J. Пайплайн над stream<T> применяет шаги к каждому
элементу (@parallel(n) пока читается как 1). on role проверяется на существование роли; шаг исполняется на текущем узле (переход на другой узел — D-5110).
Граф вызовов. Роль runs sagas обходит сгенерированное тело саги (настоящие рёбра), шаги пайплайна приписываются роли on.
И9: bench / fuzz / contract tests, snapshots, coverage (stage 5 wave 4, [С §18.1–18.2, III.9], D-7050..D-7112)#
- Harness:
oxl.test.runcallsoxl_test_drive(kind, wrapper, …)(runtime/src/testing/test_drivers.cpp) instead ofoxl_catch_panic: kind 1 = bench (calibrated batches,oxl-bench: {json}on stdout), kind 2 = fuzz (corpus replay, new inputs, shrinking, report on stderr); other kinds run once. The runner (compiler/src/driver/TestRunner.cpp + TestTools.cpp) passesOXL_TEST_ID,OXL_FUZZ_*,OXL_BENCH_TIME,OXL_SNAPSHOT_DIR,OXL_CONTRACT_SCHEMA,OXL_COVERAGE_DIRto each test process. - Fuzz arguments: sema turns
test fuzz "n" (x: T)intolet x: T = oxlFuzzArg("x"); the intrinsic passes the type'sOxlSerNodetooxl_fuzz_gen, which writes canonical JSON from a choice sequence (ChoiceSource) and decodes it — layouts, refinements and defaults stay in the serialization runtime. Shrinking minimises the choice sequence (no per-type shrinkers). - Contracts: the runner resolves
schema(old: …)to an oldoxl.schema.lock, runscompareLocks(§9.2) and hands the test a line-format old schema;old.roundtrip(&x)projects the JSON encoding ofxonto the old tags and decodes it back. - Coverage:
CGOptions::coverageadds atomic counters (CGCoverage.cpp) per statement line,if/matcharm andfailsite of the main package's files, registered by a global constructor;runtime/src/testing/coverage.cppdumps them at exit; the runner merges the dumps and renders the summary / HTML.
Source: docs/ARCHITECTURE.md