Concept
Grains are virtual actors: identity-addressed and managed by the framework. They are activated on first message and deactivated when idle. You don’t spawn them explicitly; you send to an identity and the framework routes to the right instance.The Grain interface
Every grain must implement theGrain interface:
The Grain interface
- OnActivate: called when the grain is loaded into memory (first message). Initialize state or resources. Return error to prevent activation.
- OnReceive: called for each message. Handle messages. Use
ctx.Responsefor Ask,ctx.NoErrfor Tell success. - OnDeactivate: called before the grain is removed (passivation). Persist state, release resources.
GrainContext
InsideOnReceive, GrainContext provides:
- Message: The message being processed.
- Self: Grain identity.
- Context: The context of the message; for an Ask it ends when the caller stops waiting, see Ask: the timeout reaches the grain.
- Response: Reply to an Ask.
- NoErr: Signal success for Tell (no response).
- Err: Signal failure.
- Unhandled: Mark message as unhandled.
- TellActor: Send to an actor by name.
- AskActor: Ask an actor by name.
- AskGrain: Ask another grain.
- TellGrain: Tell another grain; add
WithOneWay()to return without waiting for the acknowledgement. - RequestGrain / RequestActor: Non-blocking async request; needs reentrancy.
- DeferResponse: Hand off this Ask’s reply to a
GrainReplyfor a later turn. - PipeToSelf, PipeToGrain, PipeToActor: Run a task off the message loop, deliver its result as a message; see Grain PipeTo.
- ScheduleOnce, Schedule, ScheduleWithCron, CancelSchedule: Manage grain timers, which live with the activation. For schedules that outlive it, see grain scheduling.
Identity and messaging
Use the genericactor.GrainOf function to obtain an identity. The type parameter must be a pointer to your grain
struct; the framework derives the grain kind from it, so no factory is required. Then use TellGrain or AskGrain with
that identity. The instance name (e.g., "user-123") uniquely identifies the grain within its kind.
Identity and messaging
Options per kind
Grain options such asWithGrainMailboxCapacity and WithGrainDeactivateAfter can be given to GrainOf, but they
then belong to that call: a bare AskGrain or TellGrain that finds the grain passivated reactivates it with the
package defaults, and a grain activated on another node gets the default idle timeout. Declare them once per kind with
WithGrainDefaultOptions on the actor system instead; a GrainOf call can still override them for one grain. Each
kind gets its own declaration:
Options per kind
GrainOf is only needed to place or activate a grain with other
options than its kind’s.
Ask: the timeout reaches the grain
The timeout ofAskGrain is not only how long the caller waits. The grain knows the moment the caller stops waiting:
the ask timeout, or the deadline of the caller’s context when that comes first.
- An ask that expired in the mailbox is skipped. When the grain dequeues an ask whose caller already gave up, it
does not hand it to
OnReceive. Under load this keeps a slow grain from spending its turns on answers nobody reads, and a caller that timed out and retried does not get its work done twice. Context()ends with the ask. InsideOnReceive,ctx.Context()carries that deadline, so a handler that passes it to a database query or an HTTP call stops when nobody waits for the answer. The context also ends whenOnReceivereturns, unless the reply was handed over withDeferResponse; in that case it ends at the deadline.
Stop working when the caller is gone
Tell: acknowledged or one-way
TellGrain never returns a response payload, but by default it does wait for the grain to acknowledge the message:
the call returns once OnReceive called NoErr, Err or Unhandled, bounded by DefaultGrainRequestTimeout, and
surfaces the error the handler reported. Use this when the caller needs to know the message was processed.
Add WithOneWay() to make the call fire-and-forget. The message is enqueued in the grain mailbox, locally or on the
node that owns the grain, and the call returns without waiting for the grain to process it.
One-way tell
- Lower latency for the caller. The call costs one enqueue locally, or one round trip to the owning node. It does not pay for the grain’s processing time, nor for the messages already queued ahead of it.
- No coupling to the grain’s pace. A slow handler, a long mailbox, or a grain paused in
StashNonReentrantmode cannot make the caller wait or time out. The message simply waits its turn. - Higher throughput. A producer is bounded only by the enqueue rate, so notifications, event fan-out, telemetry and other high-rate traffic flow without blocking the sender on each message.
- Simpler senders. When the sender has nothing to do with the outcome, there is no acknowledgement to handle and
no timeout to choose. The default
TellGrainremains the right choice when the caller needs to know the message was processed.
WithOneWay():
- The caller still receives errors that occur before the enqueue: an invalid identity, a stopped system, an activation failure, a full bounded mailbox, or a transport failure toward a remote owner.
- A failure the handler reports through
ErrorUnhandled, or a panic inOnReceive, is recorded as a deadletter on the node that ran the handler. TheDeadletterevent names the grain as receiver, using its identitykind/nameas the name, and carries the message and the reason. A panic is logged as well. - The message rides the grain mailbox like any other, so ordering with other messages is preserved and the grain’s passivation activity is refreshed.
CorrelationID()is empty andDeferResponse()returns nil inside the handler, since there is nothing to reply to.
A one-way tell does not apply backpressure: with the default unbounded mailbox, a fast producer can grow the mailbox
without limit. Use
WithGrainMailboxCapacity when the producer must be slowed down by ErrMailboxFull.ErrMailboxFull, ErrRequestTimeout or ErrDead returns the same error whether the grain is
on the calling node or on another one, so errors.Is gives the same answer in both cases.
When activation is needed, the framework constructs the grain as a zero value of its struct type. Put initialization
logic in OnActivate and supply external resources (database handles, clients, configuration) through
WithGrainDependencies; they are available from the GrainProps passed to OnActivate. This is the same construction
contract the cluster applies when it recreates or relocates a grain on another node.
Calling
GrainOf automatically registers the grain kind on the calling node. In cluster mode, nodes that can host a
grain without ever resolving it themselves (for example, targets of relocation or remote activation) must register the
kind at startup, either with RegisterGrainKind or through ClusterConfig.WithGrains.Scheduled delivery
The actor system’s scheduler can deliver a message to a grain later: once after a delay, at a fixed interval, or on a cron expression.ScheduleGrainOnce, ScheduleGrain and ScheduleGrainWithCron take the grain identity, deliver
with a one-way tell, and activate the grain again if it has passivated. See Grain scheduling.
Scheduled delivery
Reentrancy
By default a grain processes one message at a time. Reentrancy lets a grain issue non-blocking requests to other grains or actors (RequestGrain, RequestActor) and keep processing while the replies are in flight, which is what
lets request cycles (A asks B while B asks A back) complete instead of deadlocking. It also unlocks DeferResponse,
which answers an incoming Ask only after an outbound request completes.
Enable it at activation with WithGrainReentrancy or at runtime with ctx.EnableReentrancy. Modes, per-call options,
deferred replies, the passivation interaction, and a complete request-cycle example are on the
Grain Reentrancy page.
PipeTo
A grain processes one message at a time, so blocking on slow I/O insideOnReceive stalls every queued message.
PipeToSelf, PipeToGrain, and PipeToActor offload that work: the task runs in a goroutine outside the message loop,
the grain keeps processing, and the outcome comes back as an ordinary message (*StatusFailure on failure for grain
targets). Methods, failure semantics, options, and a complete example are on the Grain PipeTo page.
When to use grains
- Entity-per-identity patterns (users, sessions, devices)
- Large populations that are mostly idle
- When you want the framework to manage lifecycle and placement
When to use actors
- Long-lived services
- Explicit lifecycle control
- Infrastructure components
Grain lifecycle
The framework keeps three promises to a grain: at most one activation per identity at a time, one message at a time, andOnDeactivate last.
- Activation. The first message for an identity activates the grain and calls
OnActivate. IfOnActivatefails, the caller gets the error. - Messages. Each message runs through
OnReceive, one at a time, in the order it reached the mailbox. - Passivation. Once the grain has been idle for its deactivation timeout (
WithGrainDeactivateAfter), it is deactivated andOnDeactivateruns.- Passivation waits for the message in progress to finish.
OnDeactivatenever runs duringOnReceive. - Passivation is skipped when messages are waiting in the mailbox, because the grain is not idle. The idle timer starts again.
- A reentrant grain is not passivated while a request is in flight. See Reentrancy.
- Passivation waits for the message in progress to finish.
- Messages that arrive during deactivation are not handled by the deactivating instance. Once
OnDeactivatehas finished, they are sent to a fresh activation in arrival order, and their callers get the reply from it. A caller that reached the instance in the last instant before it deactivated can still getErrDead; sending again reaches the fresh activation. - Reactivation. After passivation, the next message for the identity activates the grain again, on a new
instance. While the grain is active, a message goes straight to it and
GrainOfactivates nothing.
During shutdown
When the actor system stops, every active grain handles the messages already in its mailbox, then is deactivated. This holds for a full bounded mailbox too: the shutdown request goes past its capacity instead of being refused. A grain whoseOnDeactivate fails is named in the error Stop returns, and a grain still busy when the shutdown timeout
expires is abandoned without running OnDeactivate. The node activates no new grains from the moment shutdown begins:
- Local calls to
GrainOf,TellGrainandAskGrainfail. - Requests from other nodes are refused with
ErrSystemShuttingDownwhen they would activate a grain, while grains that are still active keep handling them until they are deactivated. - Messages queued behind a grain’s deactivation are refused with
ErrSystemShuttingDowninstead of being sent to a fresh activation.
GrainOf, GrainIdentity, TellGrain and AskGrain treat that refusal as a sign the node is leaving.
GrainOf and GrainIdentity activate the grain on the calling node. TellGrain and AskGrain send the message once
more in the same call, to the grain activated on the calling node, so a rolling restart needs no retry in the caller.
See Activation guarantee.
Activation guarantee
In cluster mode, the system claims grain ownership in the cluster registry using an atomic put-if-absent operation before activating locally. If another node already owns the grain, requests are forwarded to that owner. If local activation fails after a successful claim, the claim is removed. A failed request to a remote owner rarely displaces that owner. An activation error or a handler error reported by the owner, or a request abandoned by the caller’s own context, is returned to the caller and the registry entry is kept.GrainOf, GrainIdentity, TellGrain and AskGrain release the entry, letting the grain re-activate
elsewhere, in two cases only:
- the owner answers that it is shutting down or has shut down. For a message, the owner’s node must have refused it
before any handler ran: an
ErrSystemShuttingDownthat a grain handler returned does not release the entry; - a transport failure occurs, where the owner never answered, and the cluster membership confirms the owner’s node has left.
TellGrain and AskGrain do with the message after the release depends on the case:
- The owner refused the message. The message did not run, so it is sent once more in the same call. The calling node claims the grain with the configuration recorded in the released entry (role, mailbox capacity, reentrancy, dependencies, activation settings) and delivers the message to it. The second send uses what is left of the call’s timeout. When another node wins the claim, the message goes to that node. When the calling node cannot host the grain, because the grain kind is not registered on it or it lacks the grain’s role, the refusal is returned.
- The owner left the cluster before the request could be sent. The calling node could not connect to the owner, so the message did not run. It is sent once more in the same call, the same way.
- The owner left the cluster with the message in flight. The message may have run before the owner died, so it is not sent again: the transport error is returned. The next call reaches the grain on a live node.
ErrClusterRegistryTimeout while the registry
converges; that call can be sent again.
While a node leaves, the registry can fail a write it still routes to that node. The writes an activation and a release
need (the claim of the grain, the publication of its record and the release of an owner’s entry) are tried again a few
times before the call fails.
The owner marks its refusals on the wire. During a rolling upgrade, an
ErrSystemShuttingDown from an owner on an
older version carries no mark: the error is returned and the entry is kept, as before.OnDeactivate while a message sent to
the grain is in OnReceive.
A claim made on behalf of a remote peer follows the same rule. When the peer rejects the activation, the claim is
rolled back so the next activation can pick another node. When the outcome is unknown, after a transport failure or an
expired caller context, the claim is kept: the peer may already have activated the grain, and if it has not, it does so
with the recorded configuration on the first message routed to it.
Every release of a registry entry, by the departure cleanup, by an activation or a message that confirmed the owner
left or was refused by it, by the rollback of a claim, or by the owner’s own deactivation and shutdown, deletes the
entry only while it still names the expected node. The check and the deletion run under a cluster-wide lock on the
grain, so a release that lost a race against a newer claim leaves that claim in place.
When configured, the grain activation barrier delays grain activations until the cluster reaches the minimum peers
quorum (or until the barrier timeout elapses).
See also
- Placement: Where grains activate in a cluster: strategies, roles, and ownership
- Grain Timers: Volatile, activation-scoped timers for periodic grain behavior
- Grain Scheduling: Delayed, recurring and cron-based delivery of messages to grains
- Passivation: Grains use passivation for idle-based deactivation
- Actor Model: When to use actors vs grains