vike
Concepts

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.

kindrunscadencearmed when
StuckSweepthe watchdog's stuck-order laddersubmit_ack_timeout / 2, at least 50 mssubmit_ack_timeout is set
DeadManSweepthe silence dead-man's switchtimeout / 2, at least 50 ms — at least two checks per windowdeadman is set
LinkDeadManSweepthe connection dead-man's switchgrace / 2, at least 50 ms — the same shape, on its own windowlink_deadman is set
InflightConfirmthe reject-free early confirmthe configured interval — it is the check rateinflight_confirm is set
GtdSweepmanaged GTD/DAY expirythe configured intervalgtd_sweep is set
PortfolioSnapone compact equity-and-positions journal recordthe configured intervalportfolio_snapshot_interval and a journal are both set
EquitySamplethe equity samplerthe configured intervalequity_sample is set and any position is open
StateSaveevery mount's strategy-state savethe configured intervalstate_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.

On this page