Indestructibility simulator
Run Sockudo's deterministic disaster simulator for durable realtime and push invariants.
The Sockudo simulator is a seed-replayable disaster lab for durable Protocol V2 state. It runs
inside one process, drives real sockudo-core memory stores, calls the real sockudo-push memory
store and accept pipeline for durable push boundaries, injects node, network, IO, queue, and
fake-provider faults, and checks a shadow model
continuously.
Use it to prove that Sockudo-side durability contracts survive dropped live fanout, duplicated delivery, node crashes, pauses, partitions, stream resets, retention purges, reconnect recovery, storage-level dropped writes, torn multi-record writes, stale or corrupted reads, delayed commit visibility, push queue loss, lost write responses, retryable provider failures, invalid tokens, and repair.
The simulator deliberately does not claim that APNs, FCM, WebPush, HMS, WNS, browsers, mobile OSes, radios, or devices will deliver every notification. External providers are modeled as fallible systems. "Indestructible" means Sockudo does not lose, corrupt, double-apply logically, or make unrecoverable an accepted Sockudo-side operation.
cargo run -p sockudo-simulator --bin sockudo-sim -- --seed 42 --ticks 10000or through the Makefile wrapper:
make simulator SIM_SEED=42 SIM_TICKS=10000For a disaster-heavy JSON profile:
make simulator-disaster SIM_SEED=12648430 SIM_TICKS=50000For VOPR-style randomized distributions and liveness checks:
make simulator-swarm SIM_SEED=3735928559 SIM_TICKS=50000
make simulator-liveness SIM_SEED=3735928559 SIM_TICKS=50000Every failure prints a replay command with the seed. Keep the seed, tick count, and fault arguments unchanged when reducing a failure.
What It Exercises
The simulator covers these high-value durability surfaces:
| Surface | Oracle |
|---|---|
| Protocol delivery | V1 renderings are serialized through sockudo-protocol and must not contain V2-only fields; V2 renderings must preserve continuity fields and strip internal idempotency keys. |
| Durable history | Reserved serials, retained rows, page cursors, stream inspection, retention purges, and reset behavior match the shadow model. |
| Connection recovery | Simulated V2 clients recover dropped live fanout from durable history unless retention has legitimately truncated the gap; stale and corrupt storage reads must fail closed or return a valid prefix, and recovery cursors must remain contiguous. |
| Versioned messages | Delivery serial replay is contiguous, version chains page in both directions, cursors round-trip through JSON, latest reads match the shadow, version/history/delivery serials stay monotonic, and latest_by_history preserves original history ordering. |
| Presence history | First-join/last-leave edge decisions, retained events, cursor paging, stream inspection, and reconstructed snapshots match the shadow model under deterministic reconnect churn. |
| Push workflow | Device registration, channel subscription, scheduled-job storage, publish acceptance, initial status, publish logs, and publish-id idempotency go through real sockudo-push memory store/pipeline APIs. The simulator then faults modeled worker queues, status transitions, and provider outcomes and checks they converge. |
| Crash/restart recovery | Restarted nodes immediately re-read durable history, versions, presence history, and push status/log/idempotency state through the same safety oracles. |
| Rolling upgrades | Opt-in upgrade runs restart nodes one by one with mixed legacy/target feature gates, schema activation, V1/V2 wire checks, before/during/after durable data counters, and push status oracles. |
It does not replace live multi-node integration or Jepsen-style external testing. It is the fast, deterministic inner loop for the durable primitives those tests rely on.
Outside-In Binary Chaos Harness
Sockudo also has a separate outside-in chaos runner for manual local experiments against actual
sockudo server binaries and real client traffic. It is inspired by TigerBeetle Vortex in spirit,
but it is not deterministic, not a CI gate, and not part of the simulator's seed-replayable model.
make binary-chaos CHAOS_SEED=42 CHAOS_DURATION_MS=12000The Make target builds target/debug/sockudo, starts it as a child process with a generated local
memory-backed config, connects clients over WebSocket, publishes signed HTTP API events, and injects
bounded outside-in faults:
| Fault surface | Current behavior |
|---|---|
| Process restart/kill | Sends SIGKILL, restarts the binary, waits for /up/app-id, and reconnects clients. |
| Network delay/drop/duplication | By default runs clients and HTTP publishes through a local TCP proxy that applies seeded connection/chunk delay and stream drops. Duplicate publish probes remain application-level and reuse the same idempotency key. |
| Config changes/reloads | Writes a second config and applies it by process restart because Sockudo loads config at startup. |
| Push-provider fake outcomes | Optionally starts seeded scripts/push-mock-provider.mjs, points FCM worker env vars at it when the binary is built with push/monolith features, and can require the mock provider to produce a seeded fake outcome. |
| Recovery/reconnect | Enables Protocol V2 connection recovery, drops client connections, reconnects, and records resume success/failure counters. |
Every run writes an artifact directory under target/outside-in-chaos/<timestamp>-seed-<seed>/
containing:
| File | Purpose |
|---|---|
artifact.json | Seed, command, replay command with effective flags, generated config paths, fault timeline, counters, publish results, provider metrics, and recovery observations. |
sockudo-chaos.toml | Initial generated server config. |
sockudo-chaos-restart.toml | Restart config used for the config-change fault. |
sockudo.log | Captured server stdout/stderr. |
push-provider.log | Captured mock provider stdout/stderr when enabled. |
Push-provider exercise is opt-in because it requires a compatible feature build:
make binary-chaos-push CHAOS_SEED=42 CHAOS_DURATION_MS=12000For network experiments, the default --network-fault-mode proxy is unprivileged and local-only.
Use --network-fault-mode publisher to limit faults to HTTP publisher delay/drop behavior, or
--network-fault-mode off to run only process/client/config/push chaos. The replay command in
artifact.json reuses the same seed, ports, timings, probabilities, and push-provider settings;
outside-in wall-clock scheduling can still vary between runs.
Sockudo's provider dispatch layer rejects private/local provider URLs by design. If that guard prevents the server process from calling the local mock provider, the artifact records the guarded dispatch attempt and the harness performs a direct mock-provider probe so the fake provider outcome is still captured with the same seed.
Keep this harness local/manual only. Do not add CI, scheduled jobs, or GitHub Actions for it.
Real-Code Boundary
The simulator is intentionally not a full server-in-a-process. Its current real-code boundary is:
| Path | Boundary |
|---|---|
| History | Real MemoryHistoryStore reservations, appends, reads, cursors, stream inspection, purges, and resets. |
| Versioned messages | Real MemoryVersionStore delivery reservations, version appends, replay, latest reads, paged chain reads, and latest_by_history. |
| Presence history | Real MemoryPresenceHistoryStore transition recording, dedupe, reads, cursors, stream inspection, snapshot reconstruction, and resets; the simulator separately models active connection counts for first-join/last-leave edge decisions. |
| Push durable state | Real MemoryPushStore device, subscription, schedule, status, publish-log, and idempotency APIs, with publish acceptance through PushPipeline::accept_publish. |
Still modeled: live socket fanout, horizontal transport timing, replay-buffer hot recovery, operator routing, push worker queues after accept, provider dispatch, provider feedback, and repair timing. Those modeled paths exist to make deterministic faults cheap and replayable; they are checked against the durable stores above rather than replacing them.
Deterministic IO Model
The simulator keeps deterministic IO under crates/sockudo-simulator/src/io.rs. The production
server does not use these wrappers; they are simulator-local boundaries around modeled IO and real
memory-store calls.
| Wrapper | Controls |
|---|---|
DeterministicClock | The logical tick and durable timestamps. Timer advancement during quiesce jumps through due fanout and push work instead of sleeping wall-clock time. |
DeterministicFaultScheduler | Seeded random choices, operation trace entries, and named fault rolls such as fanout_drop, queue.ack_lost, and storage.push_publish.write_after_commit. |
| Schedule harness | A separate seed domain for simulator-executed task ordering: top-level tick tasks, due fanout deliveries, due push schedules, and ready push queue items. |
DeterministicNetwork | Modeled live fanout delivery order, delivery tick, dropped messages, duplicates, and delayed delivery. |
DeterministicQueue | Modeled push worker queue order, delayed delivery, redelivery, lease timeout duplication, and repair requeueing. |
DeterministicStorage | Storage read/write behavior around real memory-store calls: dropped writes before reservation, torn multi-record writes, fail-after-commit, lost responses, stale reads, corrupted reads, delayed commit visibility, and backend outages. |
The final JSON report includes a bounded io_trace with the most recent operation, fault,
scheduling, and logical-timer decisions. That trace is intentionally compact: it is for replay
orientation, not a full event log. Use the seed, tick count, mode, fault flags, and workload
weights as the canonical replay input.
To inspect scheduler and timer decisions for a seed:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--seed 42 \
--ticks 10000 \
--json | sed '1d' | jq -r '.io_trace[]'To replay a saved failure capsule and print the same trace window:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--corpus-file /tmp/sockudo-sim-failure.json \
--json | jq -r '.[0].io_trace[]'Current limitations:
| Limitation | Why |
|---|---|
| No real sockets or multi-process transport | Fanout is a deterministic in-process queue so drops, duplicates, and delays are exactly replayable. |
| No wall-clock timer waits | Logical ticks make timer behavior reproducible and keep shrink/replay fast. |
| No real provider calls | Provider responses are seeded outcomes so retry, invalid-token, and lost-response paths can be covered without external services. |
| No real production storage backend faults | The simulator wraps real memory stores and injects storage behavior at the simulator boundary; Redis/Postgres/disk failures still need integration or chaos tests. |
| Bounded trace memory | recent_trace, push.recent_trace, and io_trace keep recent context only; failure capsules and replay commands should preserve the full seed/config. |
VOPR-Style Maturity
The simulator has three layers:
| Layer | Command | Purpose |
|---|---|---|
| Fixed safety | make simulator | Replays one fixed profile. Use this for direct bug reproduction. |
| Swarm safety | make simulator-swarm | Randomizes topology, workload weights, page sizes, retention, and fault distributions from the seed before checking safety invariants. |
| Swarm liveness | make simulator-liveness | Runs the same deterministic swarm profile, then asserts bounded convergence after workload generation stops. |
Swarm profiles are deterministic: the seed generates both the simulator run and the profile distribution. A failing swarm seed can be replayed without recording a separate config.
Safety mode answers: did accepted Sockudo-side work remain correct? Liveness mode adds: after faults stop, did durable work become available and drain within a bounded number of ticks?
Use failure capsules when saving CI failures:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--seed 42 \
--ticks 50000 \
--swarm \
--failure-artifact /tmp/sockudo-sim-failure.jsonUse shrink mode to reduce the replay before debugging. The shrinker first proves the original config still fails, then deterministically tries smaller tick, operation, and fault prefixes, topology reductions, and workload/fault profile simplifications. It accepts only candidates that still fail and writes a smaller failure capsule:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--corpus-file /tmp/sockudo-sim-failure.json \
--shrink-failure \
--shrink-output /tmp/sockudo-sim-failure.shrunk.jsonThe shrink command prints the exact replay command, and the artifact stores the same command in
replayCommand:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--corpus-file /tmp/sockudo-sim-failure.shrunk.jsonWhen shrinking a --swarm run directly from seed flags, the artifact also includes
seedDerivedProfile. That preserves the original seed-generated topology, workload, and fault
profile before any shrink simplifications, while replay continues to use the explicit shrunk
config from the capsule. Do not pass --swarm when replaying a failure capsule.
Rolling Upgrade Replay
The simulator has an opt-in upgrade risk profile for production-style rolling changes. It keeps the run deterministic while modeling:
| Upgrade surface | Model |
|---|---|
| Rolling restarts | Nodes are restarted one at a time into the target generation, with at least one live node left serving traffic. Durable recovery oracles run after each upgraded node comes back. |
| Mixed config/features | Legacy nodes reject feature-gated versioned-message writes while target nodes accept them after the schema gate opens. Ordinary history, recovery, and push traffic continue through the mixed fleet. |
| V1/V2 compatibility | Mixed V1/V2 simulated clients keep receiving live and recovered deliveries. V1 projections must strip V2-only fields, and V2 projections must preserve continuity and upgrade metadata. |
| Durable before/after data | History, versioned messages, and push publishes are counted by before_feature_change, during_rolling_change, and after_feature_change phases in the final JSON. |
| Schema/version gates | Versioned writes are rejected before schema activation. The upgrade oracle fails if any version record appears in the pre-activation phase. |
| Push status semantics | Push publish status/log/idempotency oracles continue to run across all upgrade phases, and target nodes must use the target push-status semantics. |
Run the manual local wrapper:
make simulator-upgrade SIM_SEED=48879 SIM_TICKS=1200Or replay the same profile directly and inspect upgrade coverage:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--seed 48879 \
--ticks 1200 \
--upgrade-risk-profile \
--upgrade-require-coverage \
--json | sed '1d' | jq '.upgrade, .protocol_oracles, .push'Use a compressed schedule when reducing a failure or forcing the rollout to happen early in a short manual run:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--seed 43981 \
--ticks 320 \
--nodes 4 \
--upgrade-risk-profile \
--upgrade-require-coverage \
--upgrade-schema-prepare-tick 8 \
--upgrade-start-tick 12 \
--upgrade-schema-activate-tick 18 \
--upgrade-restart-duration-ticks 2 \
--upgrade-interval-ticks 8 \
--jsonTo preserve a failing upgrade run for exact replay:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--seed 48879 \
--ticks 1200 \
--upgrade-risk-profile \
--upgrade-require-coverage \
--failure-artifact /tmp/sockudo-upgrade-failure.json
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--corpus-file /tmp/sockudo-upgrade-failure.json \
--jsonThe replay command printed on invariant failure includes the upgrade timing flags. The full failure artifact remains the canonical replay input when workload weights, fault probabilities, topology, or retention settings were changed in addition to the upgrade flags.
Fault Model
The default workload is a weighted deterministic generator, not an ad hoc random branch in the runner. Each tick samples one action from these weights:
| Flag | Default | Action |
|---|---|---|
--weight-publish | 35 | Append a durable history message and fan it out to clients. |
--weight-create-versioned | 15 | Create a new mutable Protocol V2 message. |
--weight-mutate-versioned | 20 | Update, append to, or delete an existing mutable message. |
--weight-presence | 20 | Record a presence join/leave transition. |
--weight-recovery | 7 | Probe durable recovery for one simulated client/channel. |
--weight-purge | 2 | Purge an older retained history prefix. |
--weight-push-register | 8 | Register or update a push device. |
--weight-push-delete | 3 | Delete a push device and its subscriptions. |
--weight-push-subscribe | 8 | Subscribe a device to a channel. |
--weight-push-unsubscribe | 5 | Unsubscribe a device from a channel. |
--weight-push-publish | 12 | Accept a push publish and drive durable fanout. |
--weight-push-scheduled | 4 | Store and release a scheduled push publish. |
--weight-push-feedback | 2 | Replay duplicate provider feedback. |
--weight-push-repair | 2 | Run durable push queue repair. |
--weight-oracle | 1 | Force a full oracle sweep outside the periodic cadence. |
--swarm replaces these fixed defaults with seed-derived distributions. Use fixed weights when
reproducing a concrete bug and swarm weights when hunting for new combinations.
Set a weight to 0 to disable that action family. At least one action weight must be non-zero.
The final JSON report includes the weights and selected action counts so coverage is visible for a
seed.
The default fault model injects:
| Flag | Default | Meaning |
|---|---|---|
--drop-prob | 0.08 | Probability that a live fanout message is dropped before reaching a simulated client. |
--duplicate-prob | 0.03 | Probability that a fanout message is duplicated. |
--max-delay-ticks | 12 | Maximum deterministic fanout delay. |
--crash-prob | 0.002 | Per-tick probability that one live node crashes. |
--restart-prob | 0.020 | Per-tick probability that one crashed node restarts. |
--pause-prob | 0.002 | Per-tick probability that one live node pauses without losing durable state. |
--resume-prob | 0.030 | Per-tick probability that one paused node resumes. |
--partition-prob | 0.003 | Per-tick probability that one live node is isolated from traffic. |
--heal-prob | 0.020 | Per-tick probability that one partitioned node heals. |
--slow-prob | 0.003 | Per-tick probability that one node adds deterministic fanout delay. |
--stale-prob | 0.002 | Per-tick probability that one node rejects traffic as stale until it catches up. |
--stream-reset-prob | 0.0005 | Per-tick probability of an operator history/presence stream reset. |
--storage-drop-write-prob | 0.004 | Probability a core durable write is dropped before taking a reservation or changing the store. |
--storage-torn-write-prob | 0.002 | Probability a multi-record versioned-message operation commits history but tears before the version record. |
--storage-stale-read-prob | 0.006 | Probability a recovery read sees an older visible history prefix. |
--storage-corrupt-read-prob | 0.002 | Probability a recovery read is treated as corrupted and ignored fail-closed. |
--storage-delayed-commit-prob | 0.006 | Probability a committed core durable write is hidden from recovery reads until a later deterministic tick. |
--storage-max-commit-delay-ticks | 8 | Maximum delayed commit visibility window for core durable writes. |
--queue-produce-lost-prob | 0.015 | Probability that a push queue produce disappears after durable state was written. |
--queue-ack-lost-prob | 0.010 | Probability that a consumed push queue item is redelivered because ack was lost. |
--queue-lease-timeout-prob | 0.010 | Probability that a queue lease times out and duplicates work. |
--write-fail-before-commit-prob | 0.006 | Probability a push/device/subscription write fails before commit. |
--write-fail-after-commit-prob | 0.004 | Probability a write commits durably but reports failure. |
--response-lost-prob | 0.006 | Probability a committed write loses its response. |
--read-stale-prob | 0.010 | Probability a push worker sees a stale/empty read and fails closed. |
--provider-retryable-prob | 0.100 | Probability a fake provider returns quota/timeout/transient failure. |
--provider-reject-prob | 0.040 | Probability a fake provider permanently rejects a delivery. |
--provider-invalid-token-prob | 0.025 | Probability a fake provider reports an invalid or expired token. |
--provider-lost-response-prob | 0.020 | Probability a fake provider accepted externally but Sockudo lost the response. |
Rejected operations are part of the model: a workload request may route to a crashed or partitioned node and fail before it reaches durable storage. The shadow only advances after durable Sockudo stores accept the operation.
Storage faults are injected at simulator-local boundaries. Dropped core writes are rejected before serial reservations so the model does not create artificial gaps. Torn writes are limited to multi-record operations where a safe prefix can exist, such as a versioned-message history row committing before its version row. Delayed commits are tracked as per-channel visible prefixes; a recovery read may lag, but it must never expose a gap or invalid cursor. Corrupted reads fail closed and are retried by later recovery probes.
Push Oracles
Push disasters are checked against Sockudo-side invariants:
| Oracle | Meaning |
|---|---|
| Durable acceptance | Every accepted push publish has a durable status row and durable publish-log event. |
| Idempotency | Duplicate publish keys map back to the same logical publish and do not create a second effect. |
| Queue repair | Lost publish-log and delivery queue messages are recreated from durable state by idempotent repair scans. |
| Provider convergence | Each planned eligible target eventually becomes accepted, retryable, rejected, expired, cancelled, or dead-lettered. |
| Status transitions | Modeled publish lifecycle transitions never move backward, never leave a terminal state, and agree with terminal counters. |
| Retry bounds | Retryable outcomes schedule bounded retries and then converge. |
| Token invalidation | Invalid/unregistered/expired tokens are marked terminal and removed from future eligible sends. |
| Counters | Status counters never exceed planned logical deliveries and terminal statuses match terminal counters. |
| Safety after removal | No provider send is allowed after the model-visible state says the device is unsubscribed, deleted, or invalid. |
CI Profile
A good pull-request smoke profile is:
cargo test -p sockudo-simulator
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--seed 12648430 \
--ticks 5000 \
--jsonTo replay the checked-in seed corpus:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--corpus-file crates/sockudo-simulator/corpus/disaster-seeds.json \
--ticks 5000 \
--jsonTo replay the storage-fault corpus:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--corpus-file crates/sockudo-simulator/corpus/storage-faults.json \
--jsonFor nightly burn-in, run multiple fixed seeds and keep any new failing seed as a regression test:
for seed in 1 2 3 4 5 12648430 3735928559; do
cargo run -p sockudo-simulator --bin sockudo-sim -- --seed "$seed" --ticks 50000
doneFor nightly swarm burn-in:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--corpus-file crates/sockudo-simulator/corpus/swarm-seeds.json \
--ticks 50000 \
--swarmFor liveness-focused nightly burn-in:
cargo run -p sockudo-simulator --bin sockudo-sim -- \
--corpus-file crates/sockudo-simulator/corpus/swarm-seeds.json \
--ticks 50000 \
--mode liveness \
--swarmReading Failures
Invariant failures include the seed and simulator tick:
sockudo-sim: FAILED - reproduce with --seed 42
simulator invariant failed at tick 812 (seed=42): history serial gap on sim-channel-1: 41 then 43
replay command:
cargo run -p sockudo-simulator --bin sockudo-sim -- --seed 42 --ticks 5000 --mode safety
config_json:
{"seed":42,"ticks":5000,...}
recent trace:
tick=807 push queue produce lost for PublishLog { publish_id: "push-00000000000000000042" }
tick=811 node 2 restartedFailure artifacts written with --failure-artifact include the full config and can be replayed
directly with --corpus-file. Shrunk artifacts add a shrink block with the original and reduced
tick, operation, and fault counts plus each accepted simplification step.
Treat these as product bugs unless the invariant is deliberately stronger than the subsystem's documented contract. If the contract changes, update the simulator shadow and this page in the same change.
JSON Shape
The final JSON includes top-level core counters, a protocol_oracles object with V1/V2 delivery,
cursor, identity, monotonicity, rewind, and presence-edge check counts, storage fault counters,
restart recovery check counts, a nested push object with durable status/log counts, status
transition counts, provider results, queue losses, repair counts, phase counts for upgrade runs, a
nested upgrade object with rolling restart/schema gate/oracle coverage counters, recent push trace
entries, and top-level io_trace entries for recent deterministic operation/fault decisions. Keep sample
outputs with the exact seed and tick count that produced them so they remain replayable. A compact
example generated with --seed 12648430 --ticks 16 --json lives at
crates/sockudo-simulator/examples/disaster-report.example.json.
Extending It
Add new workloads by following the existing pattern:
- Generate the operation through the deterministic scheduler or workload generator.
- Apply it to the real Sockudo store or subsystem first.
- Advance the shadow only after the real operation succeeds.
- Add a cheap per-run oracle and a quiesce oracle.
- Add or pin a seed that fails before the fix and passes after it.