swactor-development-history/cfuzz/CYCLE_10_TIMERS.md

76 lines
4.3 KiB
Markdown

# 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