Virtual Clock
What It Is
Virtual Clock makes time deterministic in tests. VirtualClockProvider and VirtualScheduler replace the real wall clock so timeout and retry behavior can be driven by scheduler.advance(ms) calls with zero real waiting.
The example runs a real timeout DAG against a virtual scheduler: the node has a 200ms timeout, the test advances virtual time, and the run fails immediately in wall-clock terms.
How It Works
Install the virtual clock and scheduler before executing the DAG. Runtime timers then use the virtual providers instead of real setTimeout, and the test advances time explicitly.
Diagrams, Examples, and Outputs
The diagram is generated from the timeout DAG used by the CLI example.
virtual-clock timeout DAG
2 placements{
"@context": {
"@version": 1.1,
"name": {
"@id": "https://noocodec.dev/ontology/dag/name"
},
"version": {
"@id": "https://noocodec.dev/ontology/dag/version"
},
"entrypoints": {
"@id": "https://noocodec.dev/ontology/dag/entrypoints",
"@container": "@index"
},
"nodes": {
"@id": "https://noocodec.dev/ontology/dag/nodes",
"@container": "@set"
},
"outputs": {
"@id": "https://noocodec.dev/ontology/dag/outputs"
},
"node": {
"@id": "https://noocodec.dev/ontology/dag/node"
},
"dag": {
"@id": "https://noocodec.dev/ontology/dag/dag"
},
"body": {
"@id": "https://noocodec.dev/ontology/dag/body"
},
"source": {
"@id": "https://noocodec.dev/ontology/dag/source"
},
"sources": {
"@id": "https://noocodec.dev/ontology/dag/sources",
"@container": "@index"
},
"itemKey": {
"@id": "https://noocodec.dev/ontology/dag/itemKey"
},
"execution": {
"@id": "https://noocodec.dev/ontology/dag/execution"
},
"concurrency": {
"@id": "https://noocodec.dev/ontology/dag/concurrency"
},
"throttle": {
"@id": "https://noocodec.dev/ontology/dag/throttle"
},
"reservoir": {
"@id": "https://noocodec.dev/ontology/dag/reservoir"
},
"gather": {
"@id": "https://noocodec.dev/ontology/dag/gather"
},
"dagReference": {
"@id": "https://noocodec.dev/ontology/dag/dagReference",
"@type": "@id"
},
"DagReference": {
"@id": "https://noocodec.dev/ontology/dag/DagReference"
},
"from": {
"@id": "https://noocodec.dev/ontology/dag/from"
},
"path": {
"@id": "https://noocodec.dev/ontology/dag/path"
},
"candidates": {
"@id": "https://noocodec.dev/ontology/dag/candidates",
"@container": "@set"
},
"candidateDag": {
"@id": "https://noocodec.dev/ontology/dag/candidateDag",
"@type": "@id"
},
"selectedDag": {
"@id": "https://noocodec.dev/ontology/dag/selectedDag",
"@type": "@id"
},
"resultField": {
"@id": "https://noocodec.dev/ontology/dag/resultField"
},
"policy": {
"@id": "https://noocodec.dev/ontology/dag/policy"
},
"reducer": {
"@id": "https://noocodec.dev/ontology/dag/reducer"
},
"outcome": {
"@id": "https://noocodec.dev/ontology/dag/outcome"
},
"phase": {
"@id": "https://noocodec.dev/ontology/dag/phase"
},
"stateMapping": {
"@id": "https://noocodec.dev/ontology/dag/stateMapping"
},
"container": {
"@id": "https://noocodec.dev/ontology/dag/container"
},
"DAG": {
"@id": "https://noocodec.dev/ontology/dag/DAG"
},
"Placement": {
"@id": "https://noocodec.dev/ontology/dag/Placement"
},
"SingleNode": {
"@id": "https://noocodec.dev/ontology/dag/SingleNode"
},
"ScatterNode": {
"@id": "https://noocodec.dev/ontology/dag/ScatterNode"
},
"EmbeddedDAGNode": {
"@id": "https://noocodec.dev/ontology/dag/EmbeddedDAGNode"
},
"GatherNode": {
"@id": "https://noocodec.dev/ontology/dag/GatherNode"
},
"TerminalNode": {
"@id": "https://noocodec.dev/ontology/dag/TerminalNode"
},
"PhaseNode": {
"@id": "https://noocodec.dev/ontology/dag/PhaseNode"
}
},
"@id": "urn:noocodec:dag:virtual-clock-dag",
"@type": "DAG",
"name": "virtual-clock-dag",
"version": "1",
"entrypoints": {
"main": "urn:noocodec:dag:virtual-clock-dag/node/slow"
},
"nodes": [
{
"@id": "urn:noocodec:dag:virtual-clock-dag/node/slow",
"@type": "SingleNode",
"name": "slow",
"node": "urn:noocodec:node:slow",
"outputs": {
"success": "urn:noocodec:dag:virtual-clock-dag/node/end"
}
},
{
"@id": "urn:noocodec:dag:virtual-clock-dag/node/end",
"@type": "TerminalNode",
"name": "end",
"outcome": "completed"
}
]
}Mermaid source
%%{init: {"flowchart":{"nodeSpacing":92,"rankSpacing":104,"padding":28}}}%%
flowchart TB
%% virtual-clock-dag (v1)
entry_main(["main"])
entry_main --> urn_noocodec_dag_virtual-clock-dag/node/slow
urn_noocodec_dag_virtual-clock-dag/node/slow["slow"]
urn_noocodec_dag_virtual-clock-dag/node/slow -->|success| urn_noocodec_dag_virtual-clock-dag/node/end
urn_noocodec_dag_virtual-clock-dag/node/end((("end")))Run
npx tsx examples/virtual-clock.tsWhat It Lets You Do
Virtual clocks let applications test retry and timeout behavior without waiting for real wall-clock time. Use them in unit tests where backoff schedules, deadlines, and scheduler-driven work need deterministic assertions.
VirtualClockProvider and VirtualScheduler from @studnicky/dagonizer/testing replace the real wall-clock. Retry backoff intervals are driven by programmatic scheduler.advance(ms) calls rather than actual waits, making retry behavior testable in zero elapsed wall-clock time.
The example uses a flaky operation that fails on the first two attempts and succeeds on the third. Exponential backoff delays are 100ms → 200ms (300ms total virtual time). Both ClockProvider and Scheduler are restored to real time after the demonstration.
Attempt 1: fails → backoff 100ms virtual
Attempt 2: fails → backoff 200ms virtual
Attempt 3: succeeds
Total virtual time: 300ms. Real wall-clock time: ~0ms.Code Samples
/**
* virtual-clock: deterministic per-node-timeout testing under
* VirtualScheduler from @studnicky/scheduler.
*
* `RetryPolicy`'s backoff delays run on `@studnicky/retry`'s own internal
* timer (not the injected `Scheduler`), so `VirtualScheduler.advance()` no
* longer drives retry timing — that seam is gone. Per-node `timeout`
* budgets are unaffected: `Dagonizer.withNodeTimeout` arms its deadline via
* `Scheduler.current().after(ms, ...)` (src/Dagonizer.ts), so installing a
* `VirtualScheduler` before `dispatcher.execute()` still lets the deadline
* be driven deterministically with `scheduler.advance(ms)`, in zero real
* wall-clock time.
*
* The example runs a single-node DAG whose node has a 200ms `timeout` and
* never resolves on its own; advancing the VirtualScheduler past the budget
* fires the timeout deterministically.
*
* DAG definition (state, slow node, DAG): examples/dags/virtual-clock.ts
*
* Run: npx tsx examples/virtual-clock.ts
*/
import { Dagonizer } from '@studnicky/dagonizer';
import { Scheduler, SlowNode, SlowState, VirtualScheduler, dag } from './dags/virtual-clock.js';
process.stdout.write('\n=== VirtualClock: deterministic per-node timeout under programmatic time ===\n\n');
// ── Install a virtual scheduler ─────────────────────────────────────────────
const scheduler = new VirtualScheduler(0); // starts at t=0 ms
Scheduler.configure(scheduler);
// ── Run the DAG: the node never resolves on its own; the 200ms timeout ─────
// ── budget on SlowNode must fire to complete the run. ────
const dispatcher = new Dagonizer<SlowState>();
dispatcher.registerNode(new SlowNode());
dispatcher.registerDAG(dag);
const state = new SlowState();
const startedAt = Date.now();
const runPromise = dispatcher.execute('urn:noocodec:dag:virtual-clock-dag', state);
// Drive the timeout concurrently while the run awaits: yield so the node's
// `.after(200)` registers in the VirtualScheduler, advance past the budget,
// then yield again so the deadline rejection and abort propagation settle.
const advancer = (async (): Promise<void> => {
await new Promise<void>((r) => setImmediate(r)); // let the node start and register .after(200)
scheduler.advance(201); // trigger the timeout
await new Promise<void>((r) => setImmediate(r)); // flush .then() → deadlineReject + childCtrl.abort
scheduler.runAll(); // drain any remaining entries
await new Promise<void>((r) => setImmediate(r)); // flush abort propagation to the node signal
})();
const result = await runPromise;
await advancer;
const elapsedMs = Date.now() - startedAt;
// ── Restore the real scheduler so subsequent code is unaffected ────────────
Scheduler.reset();
if (result.state.lifecycle.variant !== 'failed') {
throw new Error(`expected the node timeout to fail the run, got ${result.state.lifecycle.variant}`);
}
process.stdout.write(`demonstrateVirtualClock completed: node timeout fired at 200ms virtual time, ${String(elapsedMs)}ms real time elapsed\n`);
process.stdout.write('\nLesson: VirtualScheduler still drives per-node `timeout` deadlines\n');
process.stdout.write(' (Scheduler.current().after()); it no longer drives RetryPolicy\n');
process.stdout.write(' backoff, which sleeps on substrate\'s own internal timer.\n');Details for Nerds
VirtualClockProvider. ImplementsClockProviderwith a programmaticnow()that advances by explicittick(ms)calls. Install viaClock.install(provider)before constructing the dispatcher.VirtualScheduler. ImplementsSchedulerwith a pending-timer queue. Install viaScheduler.install(scheduler). Callscheduler.advance(ms)to drain all timers whose deadline falls within the advanced time. No realsetTimeoutcalls are made.demonstrateVirtualClock(). Fully self-contained: installs providers, runs the retry sequence, advances virtual time, verifies the result, and restores real-time providers. No global state leaks.- Use in tests. Replace real providers in any test that exercises retry or timeout behavior. The pattern scales to
concurrency > 1scatter runs where each clone's retry policy is driven by the same virtual scheduler. - Restore real providers. Always call
Clock.restore()andScheduler.restore()after a virtual-clock test to avoid contaminating the real scheduler in subsequent test cases.
Related Concepts
- Example 22: Backoff strategies - RetryPolicy with each BackoffStrategy via VirtualScheduler
- Example 07: Retry Flow - retry as a flow shape in the Archivist
- Reference: Runtime - ClockProvider, Scheduler, RetryPolicy
- Reference: Testing - VirtualClockProvider and VirtualScheduler API