New benchmark group measuring named spawn/lookup (~2.4µs), group publish (linear O(N)), monitor setup, and ask roundtrip (~4.5µs). All registry operations efficient with minimal overhead vs baseline operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
28 KiB
28 KiB
Progress Log
Current Stage: Phase 1 — Research + First Improvement Cycle
Status: Cycle 16 COMPLETE
Plan Overview
- Phase 0: Codebase audit — understand current swactor architecture, existing tests, benchmarks ✅
- Phase 1: Broad survey + interleaved improvements
- Phase 2: Deeper improvements based on findings
- Phase 3: Testing methodology improvements
- Phase 4: Final evaluation & documentation
Completed This Session
Cycle 1: Fairness (Message Budget)
- Research: Studied ractor, tokio, Erlang/OTP BEAM, Linux CFS/EEVDF, libuv
- Finding:
tick_alldrained ENTIRE mailbox per actor per tick — critical fairness bug- BEAM uses 4000 reduction budget, tokio uses 128-op cooperative budget
- Swactor had zero budget — one hot actor could starve all others on same worker
- Implementation: Added
actor_message_budgettoRuntimeConfig(default: 64)- Modified
tick_allto break afterbudgetmessages per actor budget=0means unlimited (backward compatible)
- Modified
- Tests: 3 new fairness tests (hot_actor_does_not_starve_cold_actor, unlimited_budget_drains_all, budget_messages_drain_across_multiple_ticks)
- Benchmarks: Added fairness benchmark group (cold_latency_under_pressure, throughput_by_budget)
- Fixes: Updated RuntimeConfig struct literals across crates (python, runtime-dashboard, mt_benchmarks)
- Result: 45 tests pass (42 original + 3 new), all workspace crates compile
Cycle 2: Stress Tests, Benchmarks, Research Expansion
- Research: Added Kameo and Actix analysis to synthesis
- Actix uses custom Vyukov lock-free MPSC queue (why it's fastest)
- Kameo has dual bounded/unbounded mailbox, default capacity 64
- Both use vtable dispatch (not Box downcast)
- Actix has 256-message assertion guard (validates our budget approach)
- Stress tests: 6 new tests
message_ordering_preserved_under_budget— FIFO order with budget=8mt_stress_many_senders_one_receiver— 50 senders × 100 msgs, 4 threadsmt_stress_concurrent_spawn_and_send— 200 concurrent spawn+send, 4 threadsmt_chain_spawning_under_load— 50-level chain across 2 workersmt_panic_isolation_under_load— 10 panicking + 10 healthy actors, 4 threadssustained_throughput_does_not_drop_messages— 10 batches × 100 msgs
- Benchmarks: 2 new benchmark groups
msg_size: throughput and send_latency by message size (8B-4KB)contention: fanin (1-100 senders to 1 sink), cross_worker (1-4 threads)
- Result: 51 tests pass (42 original + 3 fairness + 6 stress), all workspace compiles
Cycle 3: Thread Parking (Adaptive Backoff)
- Implementation: Replaced
thread::sleepwiththread::park_timeoutin worker run loop- Workers register
thread::current()viaOnceLock<Thread>on startup send_toandspawncallThread::unpark()on target worker- Cross-worker sends from
WorkerContextalso unpark target - Zero new dependencies (uses
std::sync::OnceLock+std::thread::park_timeout)
- Workers register
- Design source: Tokio's parker state machine, Linux NO_HZ adaptive ticks
- Benefits: Parked workers wake instantly when work arrives (vs waiting for sleep timer)
- Reduces idle-to-active latency from up to 1ms to near-zero
- No overhead on hot path —
unpark()is no-op if thread isn't parked
- Tests: 1 new test (
mt_parked_worker_wakes_on_send) - Result: 52 tests pass (51 + 1 new), all workspace compiles
Cycle 4: Shutdown Fix + Bug-Inspired Tests
- Shutdown improvement:
shutdown()now unparks all workers for immediate exit- Previously, parked workers wouldn't notice shutdown until park_timeout expired
- Bug-inspired tests (5 new, from competitor bug reports):
stats_snapshot_is_read_only— from ractor #310 (destructive get_children)stats_under_load_do_not_interfere_with_processing— stats don't affect msg processingshutdown_wakes_parked_workers_immediately— validates fast shutdown with parkingmt_send_after_run_delivers_to_running_actors— from kameo #185 (startup delivery)budget_respected_even_with_self_sends— from actix #515 (mailbox bypass)
- Result: 57 tests pass, all workspace compiles
Cycle 5: Work Stealing Research + Load-Aware Placement
- Research: Deep analysis of work stealing in Tokio, Go, BEAM, ForkJoinPool
- Tokio: fixed 256-slot ring, steal-half, LIFO slot (3-use starvation cap), N/2 searcher limit
- Go: M:N scheduler, runnext + 256-slot local queue, steal-half, 4 tries with random permutation
- BEAM: unique dual approach — reactive stealing + proactive migration via check_balance()
- ForkJoinPool: owner LIFO / thief FIFO deque, even/odd queue indexing
- Feasibility analysis: Full actor migration IS mechanically possible (ActorSlot is Send), but:
- Requires push-based donation (ActorPool not Sync → no pull stealing)
- 1-tick message loss window during migration
- Significant complexity for uncertain benefit
- Implementation: Load-aware placement replaces blind round-robin
Placement::next_worker()now reads per-worker stats (num_actors + mailbox_depth)- Scan starts from rotating position → round-robin when all stats equal (initial burst)
- O(N) relaxed atomic loads per spawn, trivial for N≤8 workers
- Tests: 3 new tests
load_aware_placement_prefers_lighter_worker— imbalanced load biases toward lighter workerload_aware_placement_single_worker_degrades_gracefully— single-thread works correctlyload_aware_placement_falls_back_to_round_robin_on_fresh_runtime— even distribution before ticks
- Benchmarks: 1 new group —
placement/spawn_under_load(2t, 4t) - Result: 60 tests pass, all workspace compiles
Cycle 9: Lifecycle Hooks + Graceful Stop
- Research: Cross-framework lifecycle analysis (Erlang init/terminate, Akka preStart/postStop,
Actix started/stopping/stopped, Kameo on_start/on_stop/on_panic, Ractor pre_start/post_stop,
Stakker state-based Prep/Ready/Zombie, CAF on_exit)
- Also researched graceful stop across Erlang (gen_server:stop, exit, kill), Akka (stop, PoisonPill, Kill, gracefulStop), Actix (ctx.stop, Running::Stop), Kameo (stop_gracefully, kill), Go (context.Done)
- Key finding: most frameworks have on_stop NOT called on panic (state may be corrupt)
- Key finding: self-stop should be immediate (after current message), external stop is queued
- Implementation: Lifecycle hooks + dual-mode graceful stop
ActorInterface::on_start()andon_stop()— default no-ops, backward compatibleAnyActor::on_start()/on_stop()forwarded fromActor<A>implctx.stop_self()— immediate stop after current message viarequest_stopbufferruntime.stop_actor(addr)— external stop via StopSignal message (PoisonPill semantics)ActorSlotgainsstarted: boolandstopping: boolflagson_startcalled in tick_all before first message; panic in on_start → immediate poisonon_stopcalled in cleanup_dead for stopping (not poisoned) actors, wrapped in catch_unwind- Restarted actors get
started=falseso on_start fires again on fresh instance stops: AtomicU64added to WorkerStats and WorkerInfoContextInner::request_stop()method for same-worker immediate stop- Phase 7 cleanup_dead now handles both poisoned AND stopping actors, with on_stop context
- Tests: 12 new tests
on_start_called_before_first_message— on_start fires on first tick, before messageson_start_called_per_actor— 5 actors each get one on_start callon_start_panic_poisons_actor— panic in on_start → poisoned, no messages processedactor_can_stop_self— 5 msgs sent, stops after 3, only 3 processed, on_stop calledruntime_can_stop_actor— external stop via runtime, on_stop called, actor removedsend_to_stopped_actor_returns_error— stopped actor gone from address mapstop_vs_panic_tracked_separately_in_stats— stops and panics counted independentlyon_stop_can_send_messages— farewell message sent during on_stop is deliveredon_start_called_again_after_restart— restartable actor gets on_start on fresh instanceexternal_stop_is_queued_after_pending_messages— PoisonPill semantics for external stopexternal_stop_before_new_messages_prevents_processing— stop before send blocks msgsstop_nonexistent_actor_returns_error— stop on bad address returns Err
- Result: 82 tests pass, all workspace compiles
Cycle 16: Benchmark New Features
- Scope: Added benchmark group for features from Cycles 12-15 (named actors, groups, monitors, ask)
- New benchmarks (5 total in
registrygroup):named_spawn_lookup— spawn_named + where_is roundtrip: ~2.4µs (vs bare spawn 1.9µs → +0.5µs overhead for name registration)where_is_100_names— lookup in 100-name registry: ~9.0µs (includes setup overhead)group_publish/{10,50,100}— broadcast to N members: 4.8µs/15.5µs/60µs (linear with O(N) clones)monitor_setup— monitor + stop + cleanup: ~13.4µsask_roundtrip— ask + recv_ticking: ~4.5µs (vs manual roundtrip 3.0µs → +1.5µs for inbox creation)
- Analysis: All registry operations are efficient. Named lookup adds <1µs over bare spawn. Ask adds ~50% overhead vs manual inbox pattern (acceptable for convenience). Group publish scales linearly — expected for O(N) message cloning. No optimization needed.
- Result: All benchmarks run cleanly, 127 tests pass, zero warnings
Cycle 15: Ask Pattern (Request-Response)
- Research: Studied ask/call/request-response patterns across Erlang gen_server:call (From + reply),
Akka ask (temporary actor + Future), Ractor call (RpcReplyPort), Kameo ask (async + Reply trait),
xactor Handler (return value auto-routing)
- Key finding: swactor's synchronous tick model requires explicit reply_to, not implicit routing
- Decision: convenience wrapper over existing inbox pattern, not implicit auto-reply
- Implementation:
Ask<R>struct +Runtime::ask()methodAsk<R>: wrapsInbox<R>withtry_recv()andrecv_ticking(rt, max_ticks)rt.ask(addr, |reply_to| Msg { reply_to })— creates inbox, builds message, sends, returns Askask.recv_ticking(&rt, max_ticks)— ticks until response or timeout (single-threaded only)ask.try_recv()— poll without ticking (works in both modes)ask.reply_addr()— access inbox address for manual use- Purely sugar over
new_inbox → send_to → tick → try_recvpattern - Zero changes to ContextInner or ActorInterface — no implicit auto-reply magic
- Tests: 5 new behavioral tests
ask_recv_ticking_returns_response— basic PingPong ask roundtripask_multiple_times_tracks_state— 3 sequential asks to CounterActorask_timeout_when_no_response— ask dead actor → timeout errorask_try_recv_returns_none_before_tick— poll before tick → None, after tick → Someask_reply_addr_is_accessible— reply address is valid
- Result: 127 tests pass (120 behavioral + 7 proptest), all workspace compiles, zero warnings
Cycle 14: Actor Groups (Pub-Sub)
- Research: Studied group/pub-sub patterns across Erlang pg (scopes, join/leave/get_members),
Akka DistributedPubSub (mediator, topics), Ractor pg (join/leave/broadcast), Bastion (Dispatcher),
Redis pub/sub (channels, patterns)
- Common patterns: auto-cleanup on death, at-most-once delivery, string-based naming, flat groups (not hierarchical), lazy creation/deletion
- Decision: Erlang pg-style flat groups, string keys, auto-cleanup, RwLock pattern
- Implementation:
GroupRegistryin delivery.rs with forward + reverse mapsgroups: RwLock<HashMap<String, HashSet<ActorAddress>>>— group→membersmemberships: RwLock<HashMap<ActorAddress, HashSet<String>>>— actor→groups (reverse for cleanup)- Groups auto-create on first join, auto-delete when empty
- Runtime API:
join_group(addr, name),leave_group(addr, name),publish_to(group, msg),group_members(group),groups() - Ctx API:
join_group(name),leave_group(name),publish(group, msg),group_members(group) publishclones at the typed level (Message: Clone), sends to each member via normal routing- Auto-cleanup:
group_registry.cleanup(&addr)in cleanup_dead phase removes dead actor from all groups - ContextInner extended:
join_group(),leave_group(),group_members()(publish is Ctx-level only)
- Tests: 9 new behavioral tests
group_members_returns_joined_actors— join + queryempty_group_returns_no_members— nonexistent group → emptypublish_broadcasts_to_all_members— 2 members, both receiveleave_group_stops_receiving_publishes— leave → excluded from broadcastdead_actor_auto_removed_from_group— stop → removed from groupactor_removed_from_all_groups_on_death— multi-group membership cleanupempty_group_auto_deleted— last member leaves → group removed from groups()ctx_join_group_from_handler— join via on_startctx_publish_broadcasts_from_handler— publish via handler
- Result: 122 tests pass (115 behavioral + 7 proptest), all workspace compiles, zero warnings
Cycle 13: Actor Monitoring / Death Watch
- Research: Studied monitoring across Erlang (monitor/2, DOWN messages), Akka (watch/Terminated),
Ractor (link, SupervisionEvent), Actix (none), Kameo (link, on_link_died callback)
- Key finding: Erlang's unidirectional monitor + message delivery is the best fit for swactor (reuses existing type-erased handler, zero trait changes, composable)
- Callbacks (Ractor/Kameo style) rejected: would require adding to AnyActor/ActorInterface traits
- Bidirectional links deferred: can layer on top of monitors later
- Implementation:
MonitorRegistryin delivery.rs +Down/StopReason/MonitorRefin actor.rsMonitorRegistry:RwLock<HashMap<ActorAddress, Vec<(MonitorRef, ActorAddress)>>>(watched→watchers)- reverse
RwLock<HashMap<MonitorRef, ActorAddress>>for O(1) demonitor
- reverse
MonitorRef(u64): unique token fromAtomicU64counterDown { addr: ActorAddress, reason: StopReason }: delivered as normal mailbox messageStopReason:Normal(graceful stop) |Panicked(panic, not restartable)ctx.monitor(target)→MonitorRef— subscribe to death notificationsctx.demonitor(mref)— cancel a subscriptioncleanup_deadnow returnsVec<(ActorAddress, StopReason)>instead ofVec<ActorAddress>- After cleanup_dead: iterate dead actors, take_monitors from registry, route Down through normal delivery (pool.deliver for same-worker, transfer_txs for cross-worker, inbox_registry for inboxes)
- Dead watcher cleanup:
remove_watcher()strips monitor subscriptions for dead watchers - Multiple monitors of same target produce independent notifications (stacking, like Erlang)
- Tests: 7 new behavioral tests
monitor_notifies_on_graceful_stop— Down{reason: Normal} on stopmonitor_notifies_on_panic— Down{reason: Panicked} on panicmultiple_watchers_all_notified— two watchers both get Downdemonitor_cancels_notification— demonitor → no Down delivereddead_watcher_does_not_receive_down— dead watcher's monitors cleaned updown_delivered_to_external_inbox— Down forwarded through inboxstacked_monitors_produce_multiple_notifications— two monitors on same target → two Downs
- Result: 113 tests pass (106 behavioral + 7 proptest), all workspace compiles, zero warnings
Cycle 12: Named Actor Registry
- Research: Studied named actor/service discovery across Erlang (register/2, whereis/1, global, pg),
Actix (Registry, SystemRegistry — TypeId keys), Bastion (hierarchy-based), Ractor (String keys, DashMap,
global static), xactor (TypeId singleton), Akka (Receptionist, ServiceKey[T])
- Key findings: TypeId keys (Actix/xactor) don't fit swactor's type-erased model; global static (Ractor) breaks multi-runtime scenarios; Erlang's register/whereis is the gold standard
- Decision: String keys, RwLock (matches existing AddressMap/InboxRegistry pattern), per-runtime scope, error on collision, auto-unregister on death
- Implementation:
NameRegistryin delivery.rs with forward + reverse mapsNameRegistry:RwLock<HashMap<String, ActorAddress>>+RwLock<HashMap<ActorAddress, String>>- Forward map for O(1) name→addr lookup, reverse map for O(1) addr→name cleanup
- Added to
RuntimeasArc<NameRegistry>, threaded throughTickContext - Runtime API:
spawn_named(name, actor),where_is(name),unregister(name),registered_names() - Ctx API:
spawn_named(name, actor),where_is(name)— usable from inside handlers ContextInnertrait extended:where_is()+register_name()(private, supports both Runtime and WorkerContext)- Auto-unregister on death:
cleanup_deadphase callsname_registry.unregister_by_addr()for each dead actor - Name reservation is immediate (before spawn queue push) — prevents TOCTOU race
- Collision returns
Err("Name already registered")— original binding preserved
- Tests: 11 new behavioral tests
named_actor_lookup_returns_spawn_address— spawn_named → where_is roundtripnamed_actor_receives_messages_via_lookup— send to looked-up address worksduplicate_name_returns_error— collision error, original preservedwhere_is_returns_none_for_unknown_name— nonexistent name → Nonename_auto_unregistered_on_actor_death— stop_actor → name freedname_can_be_reused_after_actor_death— death → respawn with same namename_auto_unregistered_on_panic— panic → name freedregistered_names_lists_all— all registered names returnedmanual_unregister_frees_name_but_actor_lives— unregister doesn't kill actorctx_where_is_resolves_inside_handler— where_is from handler contextctx_spawn_named_registers_from_handler— spawn_named from handler context
- Result: 106 tests pass (99 behavioral + 7 proptest), all workspace compiles, zero warnings
Cycle 11: Property-Based Testing (proptest + fuzz extension)
- Research: Studied testing approaches across tokio (loom), Erlang (PropEr, QuickCheck, Concuerror),
Rust property-based testing (proptest vs quickcheck), cargo-fuzz, and actor-specific testing patterns.
- Ranked approaches: #1 proptest-state-machine (perfect fit for deterministic ticks), #2 extend cargo-fuzz, #3 simple proptest, #4 shuttle, #5 loom, #6 DST
- Also researched remaining feature gaps: named actors, monitoring/death watch, groups, ask pattern
- Implementation: Property-based testing suite with proptest-state-machine
- Added
proptestandproptest-state-machineto dev-dependencies - New test file:
tests/proptest_runtime.rswith 7 tests:fifo_ordering_for_any_message_sequence— FIFO preserved for 1-100 random messagesbudget_limits_per_actor_processing— budget caps per-tick processing for 2-10 actorsone_shot_timer_fires_at_correct_tick— timer with delay 1-20 fires at exact right tickinterval_timer_fires_at_correct_period— period 1-10, verifies 3 consecutive firesbounded_mailbox_never_exceeds_capacity— capacity 1-20, 1-200 messages, never exceedsspawn_n_actors_all_tracked— 1-50 actors, all unique, all in statsswactor_state_machine— stateful property test: random Spawn/Send/Tick/Stop/CheckStats sequences (up to 40 transitions, 128 cases), verifies runtime invariants after each step
- State machine test defines SwactorModel (reference) vs SwactorTest (SUT) with:
- Reference model: HashMap<id, alive> tracking expected actor lifecycle
- Invariants checked after every transition: worker count, actor placement, mailbox safety
- Automatic shrinking finds minimal failing sequences
- Extended fuzz targets (fuzz_runtime.rs) with 4 new RawAction variants:
StopActor— graceful stop via runtime.stop_actorSpawnRestartable— spawn_restartable with configurable max_restartsScheduleTimer— one-shot timer via TimerSchedulerActorScheduleInterval— interval timer via IntervalSchedulerActor
- Added 3 new actor types to fuzz: TimerSchedulerActor, IntervalSchedulerActor, RestartableEchoActor
- Added
- Bug found: State machine test immediately caught invariant mismatch: address map tracks spawned actors immediately, but per-worker num_actors lags until first tick. Fixed invariant to use <= check.
- Result: 95 tests pass (88 behavioral + 7 proptest), fuzz targets compile, zero warnings
Cycle 10: Actor Timers (Tick-Counting)
- Research: Studied timer/scheduling patterns across Erlang (timer:send_after, erlang:start_timer),
Akka (scheduleOnce, scheduler), Actix (ctx.run_later, ctx.run_interval), Kameo (tokio::time::sleep),
Tokio (tokio::time), Go (time.After, time.NewTicker)
- Also researched priority messages (REJECTED: lifecycle hooks cover 95% of use cases)
- Also researched SmallBox optimization (DEFERRED: measure allocation cost first)
- Key finding: per-worker tick-counting is ideal for swactor's synchronous model (deterministic)
- Implementation: Per-worker
TimerWheelwith deterministic tick-based schedulingOnceTimer: fire once atfire_attick, consumed after firingIntervalTimer: fire everyperiodticks, message cloned viaCloneMsgtraitCloneMsgtrait: type-erased clone for interval timer messages (blanket impl forMessage)TimerRequestenum:Once { dest, msg, ticks }|Interval { dest, msg, period }ctx.send_after_ticks(addr, msg, ticks)— one-shot timer APIctx.send_interval_ticks(addr, msg, period)— interval timer API- Phase 2.5 in tick_once: 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
- GC: interval timers for removed actors cleaned up after cleanup_dead
schedule_timeron Runtime's ContextInner: no-op with warning (timers are per-worker only)
- Bug fixed:
gc_dead_intervalswas over-aggressive — removed timers for ANY address not in the local pool, including inboxes and cross-worker actors. Fixed to only GC timers for addresses in thedeadset from cleanup_dead. - Tests: 6 new tests
one_shot_timer_fires_after_n_ticks— timer with delay=3 fires on tick 4handler_can_schedule_one_shot_timer— timer scheduled from handler, fires correctlyone_shot_timer_fires_only_once— consumed after firing, doesn't repeatinterval_timer_fires_repeatedly— period=2, fires every 2 ticks (3 fires verified)interval_timer_cleaned_up_when_actor_dies— GC removes orphaned timerstimer_with_zero_delay_fires_next_tick— delay=0 fires on next tick
- Result: 88 tests pass, all workspace compiles, zero warnings
Cycle 8: Dead Actor Cleanup (Memory Leak Fix)
- Research: Audited remaining improvement gaps, found ActorPool and AddressMap both leak permanently poisoned actors. Known class of bug in Akka (#22990), CAF (#420).
- Implementation: Automatic cleanup of poisoned actors after tick_all
AddressMap::remove()added to delivery.rsActorPool::cleanup_dead()collects and removes poisoned actors, returns their addresses- Phase 7 in tick_once: cleanup_dead → remove from address_map → update num_actors stat
- Re-publish num_actors after cleanup so stats immediately reflect removal
- Behavior change: Sends to poisoned actors now return Err (address not found) instead of silently discarding. This is better — callers learn the actor is gone.
- Tests: 2 new tests + 2 existing tests updated
dead_actor_cleaned_up_from_stats_and_address_map— good actor persists, bad actor removedbulk_dead_actor_cleanup— 20 panicked actors all cleaned up- Updated
send_to_poisoned_actor_is_a_silent_black_hole— now asserts send returns Err - Updated
poisoned_actor_messages_not_counted_as_processed— sends fail to cleaned-up actor
- Result: 70 tests pass, all workspace compiles
Cycle 7: Actor Recovery (Factory Restart)
- Research: Deep analysis of supervision/recovery across Erlang (supervision trees, restart intensity),
Akka (Resume/Restart/Stop/Escalate), Kameo (on_panic hook), Actix (Supervised trait), Ractor (SupervisionEvent)
- Erlang: fresh process via factory (MFA tuple), mailbox lost, PID changes
- Akka: replace internals but keep ActorRef stable, mailbox preserved (docs say this is usually wrong)
- Kameo: on_panic(&mut self) — risky with corrupt state after panic
- Decision: factory-based restart (Erlang-style), safest approach
- Implementation:
spawn_restartable(actor, factory, max_restarts)on Runtime and CtxActor<A>expanded from tuple struct to named fields: inner, restart_factory, max_restarts, restart_countAnyActor::try_restart(&self)trait method (default None, backward compatible)- Factory stored as
Arc<dyn Fn() -> A + Send + Sync>— cloned into fresh Actor on restart tick_allpanic handler: try_restart before poisoning, clear mailbox, fresh staterestartscounter added toWorkerStatsandWorkerInfo
- Safety: Factory fields are "cold" (never touched by handle_any), safe to read after catch_unwind
- Tests: 4 new tests
restartable_actor_recovers_after_panic— basic restart worksrestartable_actor_resets_state_on_restart— fresh state post-restartrestartable_actor_respects_max_restarts— 2 restarts then permanent poisonnon_restartable_actor_still_poisons_on_panic— backward compatibility
- Result: 68 tests pass, all workspace compiles
Cycle 6: Mailbox Backpressure
- Research: Compared backpressure across Erlang (unbounded, pobox), Actix (cap 16, do_send bypass),
Kameo (cap 64, bounded), Tokio mpsc (bounded, permit pattern), Go channels (blocking)
- Consensus: bounded by default, configurable overflow policy
- Implementation: Per-actor bounded mailboxes with configurable overflow
- Added
MailboxOverflowenum:DropNewest(discard incoming) andDropOldest(evict oldest) - Added
default_mailbox_capacityandmailbox_overflowtoRuntimeConfig - Default: capacity=0 (unbounded) — 100% backward compatible
ActorSlotstores per-actor capacity and policy (from runtime defaults)deliver()enforces bounds; dropped messages tracked viadrops_this_tickcountermessages_dropped: AtomicU64added toWorkerStatsandWorkerInfo
- Added
- Tests: 4 new tests
bounded_mailbox_drop_newest_caps_at_capacity— 50 msgs, cap 10 → only 10 deliveredbounded_mailbox_drop_oldest_keeps_newest— 10 msgs, cap 5 → newest 5 keptunbounded_mailbox_delivers_all_messages— backward compatibility checkbounded_mailbox_refills_after_processing— cap 5, process, refill works
- Result: 64 tests pass, all workspace compiles
Research Notes
- Full analysis in
CLAUDE/notes/research_synthesis.md - Baseline benchmarks in
CLAUDE/notes/baseline_benchmarks.md - Constraints in
CLAUDE/notes/constraints.md
Next Steps
- Cycle 2: Stress testing + property-based tests ✅
- Cycle 3: Adaptive backoff with thread parking ✅
- Cycle 4: Enhanced benchmarks + bug-inspired tests ✅
- Cycle 5: Work stealing research + load-aware placement ✅
- Cycle 6: Mailbox backpressure ✅
- Cycle 7: Actor recovery (factory restart) ✅
- Cycle 8: Dead actor cleanup ✅
- Cycle 9: Lifecycle hooks + graceful stop ✅
- Cycle 10: Actor timers (tick-counting) ✅
- Cycle 11: Property-based testing (proptest-state-machine + fuzz extension) ✅
- Cycle 12: Next improvement
- Candidates: named actors/registry (small effort, high value), actor monitoring/death watch, actor groups/pub-sub, SmallBox optimization
- Priority messages REJECTED (lifecycle hooks cover 95% of cases)
- LIFO slot rejected (0-5% benefit for typical workloads, not worth complexity)
Open Questions
- Should budget be configurable per-actor (not just per-runtime)?
- Is 64 the right default budget? Benchmarks show budget=32 slightly faster for throughput
Thread parking: notification mechanismRESOLVED: OnceLock + unpark()- Should load-aware placement weight mailbox depth more than actor count?
- LIFO slot for same-worker sends: worth the complexity?
Blockers
- (none)