Timers
One allocation-free, deterministic deadline wheel, advanced once per drain-loop boundary and never per message, carries every periodic sweep the live core runs.
Everything periodic in the live core rides one facility: crates/vike-core/src/timer_wheel.rs's
DeadlineTimerWheel, armed and advanced by crates/vike-core/src/runtime/timers.rs at the
drain-loop boundary. Consolidating the stuck-order watchdog, both dead-man switches, managed
expiry and the observers onto one wheel means none of them can touch the per-message fold: the
wheel is advanced once per drain batch, after the batch has folded, and an empty wheel costs a
single is_empty read. This page is written from those two modules and from the TimerKind
enum in crates/vike-core/src/runtime/mod.rs.
The wheel
It is a hashed timing wheel over a slab. Deadlines are quantized to ticks of tick_ms and
hashed into a power-of-two number of slots, each an intrusive doubly-linked list carved out of
one pre-allocated node slab with a free list. Insert reuses a free node — no allocation on the
steady path; the slab grows only when more timers are concurrently armed than ever before —
and cancel is an O(1) unlink. A TimerId carries a generation, so a stale id whose slot has
since been reused is rejected rather than cancelling the wrong timer.
The boundary advance is O(1) in the common case: a monotonically maintained lower bound on all
armed deadlines (min_tick) makes "nothing is due yet" a single integer compare regardless of
how many timers are armed. When something is due, expiry walks only the crossed slots and the
bound is re-established by one slot scan; an advance that crosses a whole rotation — a long
idle gap — takes a full pass that sorts the due nodes so emission stays in nondecreasing
deadline order.
No wall clock inside
Every method that needs "now" takes it as an injected now_ms. The wheel is a pure function of
its inserts, cancels and the values passed to advance, so a replay or harness driving it with
recorded stamps reproduces the exact expiries. A deadline never fires early — it is quantized
up to the next tick, so a coarser resolution only makes a timer fire up to one tick late — and
time only moves forward: an advance to a now_ms at or behind the cursor is a no-op. A deadline
already in the past is clamped to fire on the next advance rather than stranded in a slot the
cursor has passed.
The runtime builds it as DeadlineTimerWheel::new(50, 1024): 50 ms resolution times 1024
slots, about a 51 s rotation, which covers a typical watchdog half-timeout in the bounded
stepping path and falls back to the full-rotation pass for larger ones.
Reaching the boundary when the core is idle
The core parks in blocking_recv between messages, and a parked core never reaches the
boundary on its own. An OS waker thread injects an Ingest::Watchdog message on cadence whose
dispatch arm is empty — it exists purely to wake the loop so drive_due_timers runs. That
waker record is journaled only when a wall-clock sweep that mutates order state can ride it —
the stuck-order ladder or either dead-man's switch — and its
presence is what makes a session's
journal refuse replay; with neither on, the boundary can only run replay-neutral observers, the
waker is not written, and the session still replays. The rule is on the
replay page.
What is armed, and when
arm_boundary_timers runs once at startup and arms the first six kinds below; each of them
re-arms itself when it fires. When nothing is opted in the wheel stays empty and the advance is
skipped, byte-identical to a wheel-free runtime.
| kind | runs | cadence | armed when |
|---|---|---|---|
StuckSweep | the watchdog's stuck-order ladder | submit_ack_timeout / 2, at least 50 ms | submit_ack_timeout is set |
DeadManSweep | the silence dead-man's switch | timeout / 2, at least 50 ms — at least two checks per window | deadman is set |
LinkDeadManSweep | the connection dead-man's switch | grace / 2, at least 50 ms — the same shape, on its own window | link_deadman is set |
InflightConfirm | the reject-free early confirm | the configured interval — it is the check rate | inflight_confirm is set |
GtdSweep | managed GTD/DAY expiry | the configured interval | gtd_sweep is set |
PortfolioSnap | one compact equity-and-positions journal record | the configured interval | portfolio_snapshot_interval and a journal are both set |
EquitySample | the equity sampler | the configured interval | equity_sample is set and any position is open |
StateSave | every mount's strategy-state save | the configured interval | state_save and state_dir are set and any mount exists |
The self-rescheduling sweeps re-arm at now + tick, skipping missed ticks rather than
bursting catch-up sweeps after a long idle. drive_due_timers stamps the injected now_ms onto
every engine before acting, so a fired sweep sees the timestamp semantics the old per-message
dispatch gave it.
The last two rows are never armed at startup: maintain_equity_timer and
maintain_state_save_timer arm and disarm them at the boundary as their gate opens and closes,
and a fired one re-arms itself only while that gate still holds. The sampler stays armed while any
engine holds an open position and is cancelled the instant the book goes flat; any_position_open
tests size != 0.0 rather than map emptiness, because a closed position leaves a zero-size entry
behind forever. The state save deliberately does not gate on open positions — a breaker's trip
count or an estimator's accumulator matters whether the book is flat or not — and is cancelled
the moment every mount is gone. Two more boundary probes have no timer at all and re-walk their
subject every pass: the per-mount readiness gate, which no-ops in O(1) once every mount has
priced, and the wall-clock strategy schedule, driven off the clock value because an injected
clock may not advance between passes.
What the observers do
sample_equity resolves each engine's equity through the same resolve_equity and
CoreConfig::price_cfg the snapshot uses, notes every open position's priced-or-missing state
on its engine's price board so the warn-once set arms and
clears, and hands one row per engine plus a "TOTAL" cross-venue row (the same py_sum law as
equity_total) to the on_equity_sample sink — the core takes no data dependency, so the
caller wires persistence. save_all_strategy_state is the one save loop shared with clean
shutdown: each mount's Strategy::save_state runs under catch_unwind, a Some is written
atomically to its sidecar, and an error is logged and swallowed. write_portfolio_snap appends
a PortfolioSample — per-venue equity, balance, realized, and every open position — to the
journal best-effort; it is replay-neutral and does not advance the
snapshot cadence counter.
The portfolio record is cheap per row, not free per session
Because those records never bump the write-ahead counter that paces the replay-base Snap, an
otherwise idle journaling core never reaches the snapshot threshold, so nothing is prunable
until the shutdown Snap. A 1 s cadence on a silent weekend is about 86 000 unprunable records
a day. Size the cadence for the session — seconds to minutes for a reporting series, never
sub-second.
The watchdog
The core's cold-path sweeps — a confirm-grace ladder for orders stuck before acknowledgement, a reject-free early confirm, managed expiry, safe-state entry after a panic, the margin-call sweep and the drawdown latch.
Portfolio and snapshot
The core publishes one immutable state view through an arc-swap cell that the window reads on repaint and can never back-pressure; the portfolio inside it is the named cross-venue aggregator, with the two totals that must not be printed as one.