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

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.

piecefilewhat
Rngrng.hxoshiro256**; Rng::derive(seed, "label", i) gives independent streams (net, ties, each node, each disk)
Worldworld.h/.cppevent queue ordered by (time, seeded tie-break, seq), nodes, kill/restart, invariants, run_*
Networkworld.h/.cpplatency/jitter, drop, duplicate, reorder, per-link overrides, partitions (symmetric, one-way, timed), stats
Disk, SimDiskdisk.h/.cppper-node disk that survives a crash; unsynced bytes may vanish; write/sync failure rate with torn writes
MemEnvDiskdisk_memenv.hadapter over the storage engine's store::MemEnv (compiled when store/env_mem.h exists)
Tracetrace.h/.cppevery send/deliver/drop/fault/start/kill/note + a 64-bit running hash
History, check_registerhistory.h/.cppJepsen-style op history and a linearizability checker for read/write registers
sweep, run_seedrunner.h/.cpprun a scenario for a range of seeds, re-run the first failure and compare trace hashes
Sessionsession.h/.cppthe process-wide world behind std.time / std.test in a test sim process
C ABIsim_abi.cpp, oxl_sim.hexplicit-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.

cpp
#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:

  1. No ambient state. No std::chrono, time(), rand(), std::random_device, threads, sockets, files, getenv inside protocol code. Time is c.now() (the node's own, possibly skewed clock), randomness is c.rng(), persistence is c.disk(), the network is c.send/broadcast, delays are c.set_timer. Iterating unordered_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.
  2. A crash destroys the Process. Whatever is not on the disk (and synced) is lost. Keep durable state behind c.disk(); call sync before acknowledging. Timers and messages of the dead incarnation are discarded; an old NodeCtx copy 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.
  3. Keep the NodeCtx by value in timer lambdas ([this, c]); it is a cheap handle (world, id, incarnation). Do not keep raw references to other nodes' Process objects - go through the network (the test harness may use w.process_as<T>(id)).
  4. 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 with w.at/after. Randomise them from w.rng() (separate stream, so adding a fault does not change the network's dice).
  5. 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 a History and call check_register (linearizable register). Add your own checker for lists/queues/sagas.
  6. Sweep seeds: sweep(body, {.first = 1, .count = 500}) where body(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.h is a complete small example (ABD register + a deliberately buggy variant).
  7. 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). Use ctx.trace("...") for protocol notes. Set WorldOptions::trace_messages = false for 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#

oxl
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 sim runs the sim tests; for each seed of the seeds range the test binary is started as its own process with OXL_TEST_SEED=<n>. seeds must be a literal range (a..=b, a..b); without it the range is 1..=100.
  • The run stops at the first failing seed and prints seed N failed (K of M seeds run) and reproduce: oxl test <pkg> --exact '<name>' --seed N. The failing seed is re-run once with OXL_SIM_REPORT=1; if the second run does not fail identically (different exit code or trace hash) the report adds a not deterministic warning.
  • --seed N runs exactly that seed (and implies --kind sim). --seeds a..b (inclusive; or a count N = 1..N) overrides the range of every sim test. Both combine with --exact, -f, -t.
  • While a process has a sim session (OXL_TEST_SEED set): std.time now()/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 time now + d, and rand is seeded. std.test controls: 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.

aspectdeterministic modenormal scheduler
threadsthe calling thread only (no workers, no monitor, no timer thread)one worker per CPU + monitor + timer thread
next taskpicked from the run queue by Rng::derive(seed, "sched")LIFO deque / FIFO injection queue / stealing
clocknowNanos() = Instant.now() = Session::kMonoBase + world.now()CLOCK_MONOTONIC
timers (sleep, deadlines, select ... after, shields)World::at events; disarm = World::canceltimer thread min-heap
time advancesonly when no task is runnable: the loop runs the next world event and every event of that instant, then tasks againwall clock
preemptionoxl_preempt_flag stays 1; a loop back-edge yields after a seeded number of checks in [16, 256]monitor thread, 10 ms slice
blocking pooljobs run inline (blockingRun*); blockingSubmitDetached jobs run on the loop between task switchespool threads
file I/Osynchronous syscalls in placeio_uring / pool
real network (ioNet)refused: -ENETUNREACH + one warning; simulated nodes talk through World's networkio_uring / epoll
traceevery switch folds (task spawn number, virtual time) into a 64-bit hash-

Consequences:

  • Same seed => same interleaving, same rand/sim.rand/select choices, 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); sleep and clock.advance panic with simulation 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 polls now() never sees time move (time only advances when everyone waits) - use sleep.
  • OXL_SIM_SCHED=0 restores the legacy mode (normal scheduler + sleep advances the world, D-4102) - an escape hatch only.
  • C++: oxl::rt::det::run(fn, {.seed, .timeLimit, .preemptMin/Max, .recordSchedule, .maxSwitches}) returns a Result (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 (ctest sim_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:

registrywhereper-node content
service registryrpc/rpc.cpp node()services, methods by path, roles, drain/in-flight state, transport, node id (= NodeState::name)
actor registrysched/actor.cpp liveOf()live actors for ActorRef resolution; the creator's node is kept in the actor header
shard-actor hostactor/sactor_rt.cpp host()engine, local store, ownership cache
configurationconfig/config.cpp slot()the config loader (cfg::loader())
data directoryorm.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.

areastateclassnotes for S2
schedulergSched, tlWorker, oxl_preempt_flag, stack cache, alt-stack, coroutine tlCurrent, debug task registry, io_uring counters, poller wakeupssharea deterministic run has its own Scheduler
core valuesstr g_empty, map g_zst, type descriptors (desc.cpp), crypto tables (x6.cpp), static_once, regex/tz caches, fmt/ser tablesshareconstants / caches
thread-localsrpc/net.cpp t_lastCode/t_lastMsg, rpc/policy.cpp t_reason/t_streamDenied, orm tCaller, panic t_trace/t_origin, ser DepthGuardshare*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 servicesrpc.cpp node()doneservices, roles, id, drain
rpc process flagsrpc.cpp g_loopback, g_testMode, g_listening, g_signal, g_flushPrints, oxl_node_served_csv::csv, metricsOncenodeg_listening/csv per node; test mode stays process-wide
rpc routing / auth / auditroute.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_pendingInputnodechannels to other nodes must become simulated links (World messages)
actorsactor.cpp live()done
shard actorssactor_rt host()donenewId() uses a static mt19937_64 seeded from random_device + clock: det
configconfig.cpp slot()done
runtime flags / argvruntime_flags global(), process.cpp g_argsnoderole, profile, --set, data dir per node
dynamic configdynconf Registry::get() (+ sync thread), control start_control (socket), dyn_cluster staticsnodestarts a std::thread sync loop: det
data dirdataDir() in orm/kv/sactor/dynconfdonerpc/policy.cpp (audit dir) and dynconf/control.cpp still read --data-dir/OXL_DATA_DIR directly
kv / builtin / SQL shardskv_api gDbs/gTxs/gOpen/gCursors/gWatches, builtin_store gOpen, sql_remote gFnshare/nodehandle tables are process-unique (share); open maps keyed by directory separate with per-node dirs; cluster keys cluster:<name> collide (node)
cluster membershipcluster/node.cpp g_node/g_retire, enroll g_state, pki g_provider/g_denied, SWIM / transport instancesnodethe ClusterNode (SWIM + transport over TLS) has to run over the World's network; initialIncarnation = time(nullptr), random_device: det
coordinator / Raft gluecoord/leader.cpp g_leaders/g_thread/g_started, coord/node_glue.cpp g_node/g_decl/g_actorServer/g_startingnodethe leader loop and node glue start std::threads: det; must become tasks / world timers
callgraphcluster/callgraph g_tablesharestatic program metadata
topicstopic_api gSubs/g_storeReady/g_warming/randomMember rng, outbox_relay relay(), topic_audit q()nodemaintenance and relay std::threads, random_device: det
ORMorm gConns/gQueries/gBeginDelayMs, newTxid (random_device)nodedet ids
KMS / securityfield_kms g_kms/g_err/g_tried, sec util g_clocknode
telemetryexport S()/g_tracing/g_logsOtlp, ids g_programSample/newSpanId state, metrics reg(), builtin g_*nodespan ids come from oxl_rand_u64 (seeded in a session); the exporter thread is off in tests
logsobs/log gSink/gThreshold/gFormatnodea node prefix in sim output would help debugging
timetime_abi gCron (cron registrations)node
HTTP / gRPC / TLS / h2connection and handle tables (http1, http1srv, h2_abi, grpc_abi, tls_abi)sharereal sockets: unusable in a deterministic run
storagestore Env::Default(), CRC tablesharenodes use MemEnvDisk / per-node dirs
simulationsim g_sessionshareone World per process
nodesnode_state.cpp node list, tlOverrideshare

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 nodes n0..n{n-1} (body on n0). Without the clause, a package with a cluster declaration gets one node <role>-0 per role and a second <role>-1 for 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 a NodeState + a World node.
  • Boot: config, the layout's roles (fixed: the program's assign by is 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 / dead come 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 with OXL_TEST_RUN=<index> OXL_TEST_SEED=<n>; use a fresh OXL_DATA_DIR per 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.fail does not reach stores, D-4109 (d)); membership is an exact oracle (no SWIM); cluster.leader is 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

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