swactor-development-history/cfuzz/CYCLE_10_TIMERS.md

4.3 KiB

Cycle 10: Per-Worker Tick-Counting Timers — Development History

Commit: d58a999 · 5 files · 247 insertions, 5 deletions


Motivation

Actors often need to schedule delayed or periodic work (timeouts, heartbeats, polling intervals). Before this change, swactor had no timer mechanism — actors had to manually count ticks or rely on external scheduling. The synchronous tick model makes wall-clock timers inappropriate, but tick-counting timers are a natural fit and provide deterministic behavior.

Competitor Analysis

Framework Timer Model Deterministic?
Erlang timer:send_after, erlang:start_timer (wall-clock ms) No
Akka scheduleOnce, scheduler (wall-clock duration) No
Actix ctx.run_later, ctx.run_interval (wall-clock) No
Kameo tokio::time::sleep (wall-clock) No
Tokio tokio::time (wall-clock, pausable for testing) With time::pause()
Go time.After, time.NewTicker (wall-clock) No
Swactor Tick-counting Yes — fully deterministic

Key Insight

Swactor's synchronous tick model makes tick-counting timers uniquely valuable: a timer scheduled for "5 ticks from now" fires at exactly tick N+5, regardless of wall-clock speed. This makes timer behavior reproducible in tests and simulations — something no other framework provides natively.

Also researched but rejected: priority messages (lifecycle hooks from Cycle 9 cover 95% of use cases) and SmallBox optimization (deferred: measure allocation cost first before adding unsafe code).

Implementation

Timer Types

  • OnceTimer — fire once at fire_at tick, consumed after firing
  • IntervalTimer — fire every period ticks, message cloned via CloneMsg trait

Timer Infrastructure

  • CloneMsg trait — type-erased clone for interval timer messages (blanket impl for Message + Clone)
  • TimerRequest enum: Once { dest, msg, ticks } | Interval { dest, msg, period }
  • Per-worker TimerWheel — stores pending timers, checked each tick

Integration into tick_once

  • Phase 2.5: Fire due timers, route through full delivery system (pool.deliver for local actors, transfer_txs for cross-worker, inbox_registry for inboxes)
  • Phase 5.5: Drain timer requests from handler buffer into TimerWheel
  • After cleanup_dead: GC interval timers for dead actors

API

  • ctx.send_after_ticks(addr, msg, ticks) — one-shot timer
  • ctx.send_interval_ticks(addr, msg, period) — interval timer
  • Runtime::schedule_timer() — no-op with warning (timers are per-worker only, must be scheduled from within a handler)

Key files modified: src/actor.rs, src/worker.rs, src/runtime.rs, src/delivery.rs, tests/runtime_api.rs

Design Decisions

  • Tick-counting, not wall-clock — deterministic behavior is a core swactor advantage. Wall-clock timers would break test reproducibility and simulation fidelity.
  • Per-worker timer wheel — timers are local to the worker that owns the actor. No cross-worker synchronization needed. Timer routing uses the same delivery system as regular messages.
  • CloneMsg trait — interval timers need to clone the message for each firing. A blanket impl covers all Message + Clone types, so users don't need to implement anything extra.
  • Timer GC for dead actors — interval timers must be cleaned up when their target actor dies, otherwise they fire forever into the void.

Bug Fixed

gc_dead_intervals was initially over-aggressive — it removed timers for ANY address not in the local pool, including inboxes and cross-worker actors. Fixed to only GC timers for addresses in the dead set from cleanup_dead.

Tests Added

6 new tests (82 → 88 total):

  • one_shot_timer_fires_after_n_ticks — timer with delay=3 fires on tick 4
  • handler_can_schedule_one_shot_timer — timer scheduled from within a handler fires correctly
  • one_shot_timer_fires_only_once — consumed after firing, doesn't repeat
  • interval_timer_fires_repeatedly — period=2, fires every 2 ticks (3 firings verified)
  • interval_timer_cleaned_up_when_actor_dies — GC removes orphaned interval timers
  • timer_with_zero_delay_fires_next_tick — delay=0 fires on next tick (not same tick)

Result

  • 88 tests pass
  • All workspace crates compile
  • Bug found and fixed: over-aggressive timer GC for cross-worker addresses