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 atfire_attick, consumed after firingIntervalTimer— fire everyperiodticks, message cloned viaCloneMsgtrait
Timer Infrastructure
CloneMsgtrait — type-erased clone for interval timer messages (blanket impl forMessage + Clone)TimerRequestenum: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 timerctx.send_interval_ticks(addr, msg, period)— interval timerRuntime::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 + Clonetypes, 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 4handler_can_schedule_one_shot_timer— timer scheduled from within a handler fires correctlyone_shot_timer_fires_only_once— consumed after firing, doesn't repeatinterval_timer_fires_repeatedly— period=2, fires every 2 ticks (3 firings verified)interval_timer_cleaned_up_when_actor_dies— GC removes orphaned interval timerstimer_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