Skip to main content

Passivation and memory

You don't need a live actor for every identity in your system — only for the ones that are currently active. ReceiveTimeout is the mechanism that keeps resident actor count proportional to concurrent activity rather than to total lifetime cardinality.

The rule

Any actor that owns expensive resources — DB connections, large in-memory state, file handles — must set a ReceiveTimeout.

That includes nearly every entity actor. The hot path of your traffic is a small subset of total ids. Resident actor count should track concurrent activity, not user count.

Setting up passivation

src/Factory/LedgerActorFactory.php
$ledger = EntityRefFactory::for($spawner, WalletLedger::class)
->using($emFactory)
->withConnectionLifecycle(
acquire: static fn() => $pool->take(),
release: static fn(Connection $c) => $pool->release($c),
)
->withReceiveTimeout(Duration::seconds(60))
->withReplayPolicy(new CreateIfMissing(fn(string $id) => new WalletLedger($id)))
->handle($commandHandler)
->build();

What happens after withReceiveTimeout(Duration::seconds(60)) is set:

  1. Actor processes a user message. Internal timer starts (60s).
  2. Another user message arrives within 60s. Timer resets.
  3. 60s of silence passes. Framework delivers a ReceiveTimeout signal.
  4. Default behavior: Behavior::stopped(). The actor's PostStop fires — connection released, entity manager closed, in-memory state freed.
  5. Next message for the same id arrives. The factory notices the cached ref is dead, spawns a fresh actor, and the replay policy reloads from storage.

For the caller, this is invisible. For your memory footprint, resident actor count equals steady-state concurrent activity.

Choosing the timeout

The tradeoff is straightforward: short timeouts free memory faster but pay the re-activation cost (one DB round trip) more often. Long timeouts keep the cache warm but hold resources.

A useful heuristic: target the median inter-message gap multiplied by 5.

  • Users touch their wallet once every ~10 seconds → 60s timeout. Active sessions stay resident; idle sessions evaporate.
  • Chat room messages are bursty, then quiet for minutes → 5-minute timeout. You don't want to reload room state mid-conversation.
  • IoT sensor pings every 30 seconds predictably → 5-minute timeout. The sensor is always hot; you're effectively pinning it.

Measure the inter-message gap before guessing.

Disabling passivation mid-lifecycle

Sometimes you need to suppress passivation temporarily. A wallet that opened a long-running transaction shouldn't passivate between BEGIN and COMMIT:

src/Actor/LedgerActor.php
private function openTxn(ActorContext $ctx): Behavior
{
$ctx->setReceiveTimeout(null); // don't passivate while a txn is open
return Behavior::same();
}

private function closeTxn(ActorContext $ctx): Behavior
{
$ctx->setReceiveTimeout(Duration::seconds(60)); // back to passivatable
return Behavior::same();
}

Call $ctx->setReceiveTimeout(null) inside the handler to cancel the timer. Restore it with a Duration when the actor returns to a passivatable state.

Per-request actors

If your actor's lifetime is naturally one HTTP request, don't use ReceiveTimeout at all:

src/Bootstrap/AppBootstrap.php
$app->perRequestActor(
'request-ctx',
Props::fromBehavior(RequestContextActor::behavior()),
);

The framework spawns one per request and stops it after the response goes out. The request boundary is the passivation boundary; no timer needed.

Reload policies

Whatever your ReplayPolicy says is what gets re-loaded on re-activation:

  • FailIfMissingfind($class, $id) or throw. Use when the aggregate must already exist; a missing id is a bug in the caller.
  • CreateIfMissing($factory) — find or create and persist. Use for "spawn on first interaction" aggregates such as wallets or chat rooms.
  • OnDemand — defer the load to the first command's handler. Use when the entity is large and you want the actor to start immediately, paying the load cost only when a command actually needs the state.

CreateIfMissing is the right default for user-facing aggregates. FailIfMissing is safer for protocol-created entities where a missing id indicates a bug.

What passivation costs

Be honest about the tradeoffs:

  • Cold-start latency. Re-activation pays one DB round trip plus actor-spawn overhead. Negligible for most workloads; real for latency-sensitive ones.
  • Lost in-memory caches. Whatever the actor accumulated in PHP-level state is gone on passivation. If the actor maintains a hot lookup table that's expensive to rebuild, keep it resident.
  • Death-watch interactions. If parent A was watching child B and B passivates, the watch fires as Terminated(B). Make sure your watcher distinguishes "B died permanently" from "B passivated and will respawn."

For most CRUD-style aggregates, none of these matter. For specialised actors — workflow engines, in-memory routing tables — they might. Keep those resident.

Next steps