Deterministic simulation (test sim, [С §18.4])
Stage 4, wave 2 (agent SIM, D-4100..D-4110). Code: runtime/src/sim/, C ABI runtime/include/oxl_sim.h, OxL controls
std/test/test.oxl, runner compiler/src/driver/TestRunner.cpp, tests tests/unit/runtime/sim_test.cpp,
conformance/§18.4/.
What it is#
One oxl::sim::World = N virtual nodes in one thread with virtual time. Nothing in it touches the wall clock, threads, the real
network or the real disk, so the outcome of a run is a function of (seed, configuration, protocol code) only. A failing seed
reproduces exactly: oxl test --seed N.
| piece | file | what |
|---|---|---|
Rng | rng.h | xoshiro256**; Rng::derive(seed, "label", i) gives independent streams (net, ties, each node, each disk) |
World | world.h/.cpp | event queue ordered by (time, seeded tie-break, seq), nodes, kill/restart, invariants, run_* |
Network | world.h/.cpp | latency/jitter, drop, duplicate, reorder, per-link overrides, partitions (symmetric, one-way, timed), stats |
Disk, SimDisk | disk.h/.cpp | per-node disk that survives a crash; unsynced bytes may vanish; write/sync failure rate with torn writes |
MemEnvDisk | disk_memenv.h | adapter over the storage engine's store::MemEnv (compiled when store/env_mem.h exists) |
Trace | trace.h/.cpp | every send/deliver/drop/fault/start/kill/note + a 64-bit running hash |
History, check_register | history.h/.cpp | Jepsen-style op history and a linearizability checker for read/write registers |
sweep, run_seed | runner.h/.cpp | run a scenario for a range of seeds, re-run the first failure and compare trace hashes |
Session | session.h/.cpp | the process-wide world behind std.time / std.test in a test sim process |
| C ABI | sim_abi.cpp, oxl_sim.h | explicit-world functions (oxl_sim_*) and the std.test controls (oxl_simt_*) |
Writing a protocol for the simulation (Raft / SWIM authors)#
A node is a Process created by a factory on every boot. All its inputs arrive as callbacks; all its effects go through NodeCtx.
#include "sim/world.h"
using namespace oxl::sim;
class MyProto : public Process {
public:
explicit MyProto(NodeCtx& c) { /* recover durable state from c.disk() (a SimDisk or MemEnvDisk) */ }
void on_start(NodeCtx& c) override { c.set_timer(150 * kMs, [this, c] { NodeCtx cc = c; tick(cc); }); }
void on_message(NodeCtx& c, NodeId from, const std::string& bytes) override { /* decode, act, c.send(...) */ }
};
World w({.seed = 42});
NodeId a = w.add_node("a", [](NodeCtx& c) { return std::make_unique<MyProto>(c); });Rules that keep a protocol deterministic and simulation-friendly:
- No ambient state. No
std::chrono,time(),rand(),std::random_device, threads, sockets, files,getenvinside protocol code. Time isc.now()(the node's own, possibly skewed clock), randomness isc.rng(), persistence isc.disk(), the network isc.send/broadcast, delays arec.set_timer. Iteratingunordered_map/pointer-keyed containers is fine only if the order does not matter (or is sorted) - it is the one way a protocol can still be nondeterministic. - A crash destroys the
Process. Whatever is not on the disk (and synced) is lost. Keep durable state behindc.disk(); callsyncbefore acknowledging. Timers and messages of the dead incarnation are discarded; an oldNodeCtxcopy becomes a no-op (alive() == false), so a "zombie" cannot act. Messages in flight to a node reach its next incarnation if it is up by then. - Keep the
NodeCtxby value in timer lambdas ([this, c]); it is a cheap handle (world, id, incarnation). Do not keep raw references to other nodes'Processobjects - go through the network (the test harness may usew.process_as<T>(id)). - Faults are the test's job, not the protocol's:
w.net().partition/block/drop/duplicate/reorder/latency,w.kill/restart/crash_for,w.set_clock_skew,w.set_disk_fail_rate, all schedulable withw.at/after. Randomise them fromw.rng()(separate stream, so adding a fault does not change the network's dice). - Check with oracles.
w.add_invariant(name, fn)runs after every event (Raft: at most one leader per term, log matching, committed entries never lost);NodeCtx::fail(msg)/World::fail(msg)stop the run. For client-visible behaviour record aHistoryand callcheck_register(linearizable register). Add your own checker for lists/queues/sagas. - Sweep seeds:
sweep(body, {.first = 1, .count = 500})wherebody(World&)builds the scenario, runs it, returns""or the failure text. The report names each failing seed, whether it reproduced (same trace hash), the trace tail and the command.tests/unit/runtime/sim_toy_register.his a complete small example (ABD register + a deliberately buggy variant). - Debugging:
World::trace().render(40)shows the last events (send/deliver/drop/part/heal/kill/start/note);Trace::first_divergence(a, b)locates where two runs of "the same" seed differ (a nondeterminism bug in the protocol). Usectx.trace("...")for protocol notes. SetWorldOptions::trace_messages = falsefor long sweeps (faster; hash still covers faults).
WorldOptions::randomize_ties (default on) runs events with equal timestamps in a seeded random order. This is what makes a
single-threaded simulation explore interleavings; leave it on.
Storage engine on a simulated node#
w.add_node("n1", factory, oxl::sim::memenv_disks()) gives the node a store::MemEnv (fault-injecting, deterministic crash model of
the LSM store). w.disk_as<MemEnvDisk>(id)->env() is the store::Env* to open the store with; kill calls MemEnv::Crash(), restart
calls Restart(), set_disk_fail_rate sets write_error/sync_error, and virtual time advances MemEnv's clock. (The adapter is
header-only and compiled only when store/env_mem.h is on the include path - i.e. after the LSM branch is merged; it was checked
against that branch's headers but is not built in this tree.)
oxl test --kind sim#
use std.test { net, node, disk, clock, sim }
test sim "checkout under faults" seeds 1..=500 {
net.partition(["api"], ["storage"], for: 2s)
net.latency(10ms, 300ms) // the Standard's `10ms..300ms` needs Duration ranges (D-4109)
node.kill("saga-0", after: 1s)
clock.advance(1h)
assert checkout(cart) is Order | PayError
}oxl test --kind simruns the sim tests; for each seed of theseedsrange the test binary is started as its own process withOXL_TEST_SEED=<n>.seedsmust be a literal range (a..=b,a..b); without it the range is1..=100.- The run stops at the first failing seed and prints
seed N failed (K of M seeds run)andreproduce: oxl test <pkg> --exact '<name>' --seed N. The failing seed is re-run once withOXL_SIM_REPORT=1; if the second run does not fail identically (different exit code or trace hash) the report adds anot deterministicwarning. --seed Nruns exactly that seed (and implies--kind sim).--seeds a..b(inclusive; or a countN=1..N) overrides the range of every sim test. Both combine with--exact,-f,-t.- While a process has a sim session (
OXL_TEST_SEEDset):std.timenow()/Instant.now()read the world's virtual clock (starting at 2026-01-01T00:00:00Z), the program runs on the deterministic scheduler (below),sleep(d)parks the task until virtual timenow + d, andrandis seeded.std.testcontrols:net.{latency,drop,duplicate,reorder,partition,block,heal,isBlocked},node.{kill,restart,isUp},disk.fail,clock.{advance,set,now},sim.{seed,rand,note,traceHash}.
The deterministic scheduler (stage 5, wave 1: D-6200..D-6209)#
A test sim process runs its whole OxL program on the deterministic scheduler (runtime/src/sched/det.h/.cpp): the root task
and every task it spawns run on the main thread, one at a time; nothing else in the process runs OxL code.
| aspect | deterministic mode | normal scheduler |
|---|---|---|
| threads | the calling thread only (no workers, no monitor, no timer thread) | one worker per CPU + monitor + timer thread |
| next task | picked from the run queue by Rng::derive(seed, "sched") | LIFO deque / FIFO injection queue / stealing |
| clock | nowNanos() = Instant.now() = Session::kMonoBase + world.now() | CLOCK_MONOTONIC |
timers (sleep, deadlines, select ... after, shields) | World::at events; disarm = World::cancel | timer thread min-heap |
| time advances | only when no task is runnable: the loop runs the next world event and every event of that instant, then tasks again | wall clock |
| preemption | oxl_preempt_flag stays 1; a loop back-edge yields after a seeded number of checks in [16, 256] | monitor thread, 10 ms slice |
| blocking pool | jobs run inline (blockingRun*); blockingSubmitDetached jobs run on the loop between task switches | pool threads |
| file I/O | synchronous syscalls in place | io_uring / pool |
real network (ioNet) | refused: -ENETUNREACH + one warning; simulated nodes talk through World's network | io_uring / epoll |
| trace | every switch folds (task spawn number, virtual time) into a 64-bit hash | - |
Consequences:
- Same seed => same interleaving, same
rand/sim.rand/selectchoices, same virtual times, same output. The runner's check of a failing seed (OXL_SIM_REPORT=1,oxl-sim: ... trace=<hash> switches=<n>) now covers the task schedule as well as the world (D-6204), so a nondeterministic program is reported as such. - Concurrent sleeps overlap: three tasks sleeping 1 s each take 1 s of virtual time (the legacy mode advanced the world by every sleep in turn, and crashed with tasks sleeping on several worker threads).
clock.advance(d)/clock.set(t)from a task = sleeping until then (other tasks run at their own times, D-6206);sleepandclock.advancepanic withsimulation failed: <reason>when a world invariant broke meanwhile.- All tasks blocked and no event pending => the program panics
deadlock: every task of the deterministic simulation is blocked ...(exit 101); a program still waiting after 24 h of virtual time (OXL_SIM_TIME_LIMIT=<seconds>) panics likewise. A busy loop that pollsnow()never sees time move (time only advances when everyone waits) - usesleep. OXL_SIM_SCHED=0restores the legacy mode (normal scheduler +sleepadvances the world, D-4102) - an escape hatch only.- C++:
oxl::rt::det::run(fn, {.seed, .timeLimit, .preemptMin/Max, .recordSchedule, .maxSwitches})returns aResult(finished/deadlock/timeLimit/livelock/worldFailed,traceHash,switches,schedule,foreignWakeups); it uses the current session's world or makes one. Tests:tests/unit/runtime/det_sched_test.cpp,tests/unit/runtime/node_state_test.cpp,tests/sim/sim_determinism.py(ctestsim_determinism), conformance§18.4/sim-concurrent-tasks-share-virtual-time.
Logical nodes in one process: NodeState (D-6210..D-6215)#
runtime/src/node_state.h: a NodeState (id, name, data dir, roles, simNode) is one logical node. A task belongs to a node
(Task::node, inherited by every child, also by tasks of the background scope); spawnOnNode(n, fn) starts a task on a node;
outside tasks NodeScope sets the thread's current node. nullptr = the process node = the original process-wide registries
(a program that never creates a node behaves as before). On the deterministic scheduler "switching the current node" is implicit:
it is the running task's node.
A registry is made per node by routing its accessor through nodeLocal<T>() (T per node, created on first use; nullptr for the
process node). Converted so far:
| registry | where | per-node content |
|---|---|---|
| service registry | rpc/rpc.cpp node() | services, methods by path, roles, drain/in-flight state, transport, node id (= NodeState::name) |
| actor registry | sched/actor.cpp liveOf() | live actors for ActorRef resolution; the creator's node is kept in the actor header |
| shard-actor host | actor/sactor_rt.cpp host() | engine, local store, ownership cache |
| configuration | config/config.cpp slot() | the config loader (cfg::loader()) |
| data directory | orm.cpp, kv_api.cpp, sactor_rt.cpp, dynconf/registry.cpp dataDir() | NodeState::dataDir when set; kv / builtin stores are keyed by directory, so they separate too |
Process-global state (inventory, stage 5 wave 1)#
Taken from nm of liboxlrt.a (every mutable data/bss symbol incl. function-local statics and thread-locals) plus the threads
the runtime starts. Classes: share = fine to share between simulated nodes; node = must be per node for several nodes in
one process; det = a nondeterminism source under simulation; done = converted in this wave.
| area | state | class | notes for S2 |
|---|---|---|---|
| scheduler | gSched, tlWorker, oxl_preempt_flag, stack cache, alt-stack, coroutine tlCurrent, debug task registry, io_uring counters, poller wakeups | share | a deterministic run has its own Scheduler |
| core values | str g_empty, map g_zst, type descriptors (desc.cpp), crypto tables (x6.cpp), static_once, regex/tz caches, fmt/ser tables | share | constants / caches |
| thread-locals | rpc/net.cpp t_lastCode/t_lastMsg, rpc/policy.cpp t_reason/t_streamDenied, orm tCaller, panic t_trace/t_origin, ser DepthGuard | share* | on one thread every task sees the same TLS: each must be read right after it is set, with no suspension in between (true today) |
| rpc services | rpc.cpp node() | done | services, roles, id, drain |
| rpc process flags | rpc.cpp g_loopback, g_testMode, g_listening, g_signal, g_flushPrints, oxl_node_served_csv::csv, metricsOnce | node | g_listening/csv per node; test mode stays process-wide |
| rpc routing / auth / audit | route.cpp g_channels/g_stats/g_rr, auth.cpp g_auth/g_declared, policy.cpp g_sink/g_sinkUser, schema_guard st(), net.cpp net() (client transport), net_stream g_pendingInput | node | channels to other nodes must become simulated links (World messages) |
| actors | actor.cpp live() | done | |
| shard actors | sactor_rt host() | done | newId() uses a static mt19937_64 seeded from random_device + clock: det |
| config | config.cpp slot() | done | |
| runtime flags / argv | runtime_flags global(), process.cpp g_args | node | role, profile, --set, data dir per node |
| dynamic config | dynconf Registry::get() (+ sync thread), control start_control (socket), dyn_cluster statics | node | starts a std::thread sync loop: det |
| data dir | dataDir() in orm/kv/sactor/dynconf | done | rpc/policy.cpp (audit dir) and dynconf/control.cpp still read --data-dir/OXL_DATA_DIR directly |
| kv / builtin / SQL shards | kv_api gDbs/gTxs/gOpen/gCursors/gWatches, builtin_store gOpen, sql_remote gFn | share/node | handle tables are process-unique (share); open maps keyed by directory separate with per-node dirs; cluster keys cluster:<name> collide (node) |
| cluster membership | cluster/node.cpp g_node/g_retire, enroll g_state, pki g_provider/g_denied, SWIM / transport instances | node | the ClusterNode (SWIM + transport over TLS) has to run over the World's network; initialIncarnation = time(nullptr), random_device: det |
| coordinator / Raft glue | coord/leader.cpp g_leaders/g_thread/g_started, coord/node_glue.cpp g_node/g_decl/g_actorServer/g_starting | node | the leader loop and node glue start std::threads: det; must become tasks / world timers |
| callgraph | cluster/callgraph g_table | share | static program metadata |
| topics | topic_api gSubs/g_storeReady/g_warming/randomMember rng, outbox_relay relay(), topic_audit q() | node | maintenance and relay std::threads, random_device: det |
| ORM | orm gConns/gQueries/gBeginDelayMs, newTxid (random_device) | node | det ids |
| KMS / security | field_kms g_kms/g_err/g_tried, sec util g_clock | node | |
| telemetry | export S()/g_tracing/g_logsOtlp, ids g_programSample/newSpanId state, metrics reg(), builtin g_* | node | span ids come from oxl_rand_u64 (seeded in a session); the exporter thread is off in tests |
| logs | obs/log gSink/gThreshold/gFormat | node | a node prefix in sim output would help debugging |
| time | time_abi gCron (cron registrations) | node | |
| HTTP / gRPC / TLS / h2 | connection and handle tables (http1, http1srv, h2_abi, grpc_abi, tls_abi) | share | real sockets: unusable in a deterministic run |
| storage | store Env::Default(), CRC table | share | nodes use MemEnvDisk / per-node dirs |
| simulation | sim g_session | share | one World per process |
| nodes | node_state.cpp node list, tlOverride | share |
OxL services on simulated nodes (stage 5 wave 2, S2, D-6250..D-6262)#
Code: runtime/src/sim/oxl_nodes.{h,cpp}, the layout in compiler/src/codegen/CGTest.cpp (simLayout), gtests
tests/unit/runtime/sim_nodes_test.cpp, conformance §18.4/sim-services-on-simulated-nodes, §18.4/sim-node-crash-during-a-call,
ctest shop_sim (tests/shop/run_shop_sim.py).
- Layout (D-6252):
test sim … with cluster(nodes: n, roles: [...])makes nodesn0..n{n-1}(body on n0). Without the clause, a package with aclusterdeclaration gets one node<role>-0per role and a second<role>-1for roles that run sagas or leader tasks (the shop: api-0, storage-0, saga-0, saga-1, gpu-0, ml-0; body on api-0). Each node is aNodeState+ a World node. - Boot: config, the layout's roles (fixed: the program's
assign byis ignored),provide(the first node that may provide a value does; values are process-wide, D-6253),oxl_rpc_register, saga / pipeline recovery loops - the generated startup (oxl.test.startup), run in a task of the node. - Calls (D-6258): a call that the calling node does not serve goes to a live node that does (seeded choice) as a World message;
the receiving node runs the rpc's wire entry (decode, policy, idempotency, handler, encode) in a task of its own and answers with
another message. Latency, drop, duplicate, reorder and partitions apply; a lost message ends at the caller's deadline. The caller
is a trusted cluster peer (
x-oxl-via, D-6259). - Crash / restart (D-6254):
node.kill(name)cancels every task of the node and fails what it waits for; its answers are lost.node.restart(name)drops its registries and boots it again. Stores are shared by all nodes and survive (D-6256). - Membership (D-6257):
cluster.nodes()lists the logical nodes;alive/deadcome from the World at once. - Debugging:
OXL_SIM_NODES_DEBUG=1(one line per boot / call / reply / crash),OXL_TEST_KEEP_BINARY=<path>(keep the test binary; run one seed withOXL_TEST_RUN=<index> OXL_TEST_SEED=<n>; use a freshOXL_DATA_DIRper run, D-6255).
Still missing (open items):
- Storage is one shared, reliable store (no Raft replication of the storage role, no per-node disk,
disk.faildoes not reach stores, D-4109 (d)); membership is an exact oracle (no SWIM);cluster.leaderis not elected (runs as on a single node). - The router's EWMA / breaker / retry-on-another-node (
route.cpp) is not used by the simulated transport; streaming rpcs (server / client / bidi streams) between simulated nodes are not carried (unary only);S.connect(addr)clients use the real network, which the deterministic scheduler refuses - mock them. - Process-wide flags of rpc (
g_testMode,g_listening),runtime_flags/ argv, KMS, telemetry, logs, cron registrations are still process-wide (one value for every node).
A full example: Raft#
tests/unit/raft/ is the largest protocol written for the kernel so far (stage 4, D-4340..D-4343): raft_harness.h (the RaftNode process,
oracle, parameters), raft_scenario.h (invariants checked after every event, clients, fault schedule, end-of-run checks), raft_kv_check.h
(linearizability of put/get/cas) and raft_sim_test.cpp (the sweeps and the injected-bug tests). More seeds: OXL_RAFT_SEEDS=500 OXL_RAFT_FIRST_SEED=1000 test_raft --gtest_filter='RaftSim.Sweep*'.
A failing seed prints the reproduction command; describe(cl) in the failure text shows every node's role, term, commit, log range, configuration and the
leader's per-follower progress.
Исходник: docs/testing/simulation.md