GoAkt 4.5.5 Cuts Grain Heap And Tightens Cluster Claims


GoAkt is a distributed actor runtime for Go. Grains are its virtual actors, activated on first message, passivated when idle, and claimed once across a cluster. This week the project shipped v4.5.4 and v4.5.5, and the code that landed is about grain density and cluster ownership, not new public APIs.

Eleven commits on Tochemey/goakt touched 145 files with 14016 insertions and 10144 deletions. The operator visible work sits in actor/grain_engine.go, actor/grain_pid.go, and actor/actor_system.go. Test fixture moves and a docs cleanup inflate the diff.

Cluster grains are owned by a registry record. Until this week a failed activate call to a remote owner could delete that record. The next node then claimed the identity. You got two activations, or a live owner the cluster no longer named.

The grain activation fix in v4.5.5 changes the release rule. RemoveGrain is gone from this path. Releases now go through ReleaseGrain, which deletes the entry only while it still names the expected node, under a cluster wide lock on that identity.

A failed request is not enough to drop the owner:

  • An error the owner itself returned stays with the caller. The registry entry stays.
  • A caller context that expires is treated the same way. The owner may already have activated.
  • Only a transport failure (net.OpError, io.EOF, a closed duplex, or ErrRemoteSendFailure) makes the activating node ask cluster membership. The entry is released only if that node has left.

A claim made for a remote peer follows the same split. If the peer rejects the activation, the claim is rolled back so the next call can pick another node. If the outcome is unknown, the claim is kept. The peer activates with the recorded config on the first message that lands there.

That is the paging class of bug. Duplicate grain activations corrupt session state. The new path is slower to recover a dead owner, because membership has to confirm the leave, and that is the correct tradeoff.

PID.reset() used to flip relocationState back to true and clear singletonState. Restart reruns init() on the same PID without reapplying spawn options. An actor spawned with WithRelocationDisabled therefore became relocatable after Restart. A stopped PID still sitting in the tree, waiting for DeathWatch, also reported the wrong flags to IsRelocatable, IsSingleton, and the remote state endpoint.

The fix that preserves relocation state leaves those two bits alone. They are spawn time config. Tests now assert that a child spawned with WithRelocationDisabled stays that way after Shutdown and after Restart.

preShutdown had a matching hole. DeathWatch removes a stopped actor from the tree asynchronously, so a user stopped actor could still appear in the graceful shutdown snapshot and get resurrected on another node. The snapshot now skips any PID that is not running, or is already stopping:

if !actor.isStateSet(runningState) || actor.isStateSet(stoppingState) {
    continue
}

The relocation docs spell that out. If you pin actors to a node with WithRelocationDisabled, upgrade. A rolling restart of the actor, not even of the node, was enough to undo the pin.

v4.5.4 already cut idle actor heap from about 2124 B to 1268 B. v4.5.5 does the same job for grains. An idle grain drops from about 1591 B to 728 B of live heap. A long lived grain with WithLongLivedGrain drops from about 1418 B to 555 B. One million idle grains on one node go from about 1.59 GB to 0.73 GB.

The grain memory work in actor/grain_pid.go is layout, not a new scheduler. The default user mailbox is two atomic pointers on the process, not a separate heap object. The async response queue exists only when reentrancy is on. The timer registry is created by the first timer in an activation and dropped when the activation ends.

Per grain copies of the remoting client, the logger, and a dedicated shutdown channel are gone. GrainIdentity keeps one kind/name string, with kind and name as views into it. The process struct is 192 B across three cache lines, so senders do not share a line with the turn that writes.

AskGrain used to allocate 128 B in two objects for its reply channel. That channel is now borrowed from a per shard pool, the same way TellGrain already pooled its ack channel. A timed out request abandons the channel so a late reply cannot land on a reused one. The project reports that on an 8 thread Apple M1 the aggregate rate across eight caller and grain pairs moved from about 2.5M to about 2.9M messages per second. Single caller rate is unchanged within noise.

Semantics are unchanged. One message at a time, send order preserved, TellGrain returns when the grain has processed the message. The new ceiling is pinned in the benchmark at 875 live heap bytes per idle grain, about twenty percent above the 728 B measured on arm64. The run fails if idle heap per grain climbs past it.

The most recent commit adds an agentic guide. It is documentation only. GoAkt still does not call models and does not manage prompts. The page argues that an agent is an actor whose decision comes from a model, and then maps wait, failure, and placement onto pieces that already exist: dispatcher pool, grains, PipeTo, reentrancy, DeferResponse, supervision.

Read it as a positioning document, not as a new subsystem. If you already run grains as long lived sessions, nothing in that page changes the binary. If you were about to write a custom session manager on top of goroutines, the page is a useful list of what you would be reimplementing.

Upgrade to v4.5.5 if the cluster runs grains, or if any actor is spawned with WithRelocationDisabled. Those two fixes are the reason to move. The heap cut comes with the same tag.

Dockerfile.tools now starts from golang:1.27.0-alpine. go.mod still declares go 1.26.0. The image bump is for the Makefile toolchain, not a module language bump. Do not assume 1.27 is required to build consumers.

Grain activation is now conservative about dropping owners. A node that is partitioned but still in the member list will keep its claims. Plan for that: a stuck owner stays the owner until membership agrees it has left.