Writing actors
This guide covers how to implement actors in Francis: the factory, the methods an actor can implement, how to invoke actors, and how to manage an actor’s lifecycle.
Anatomy of an actor#
An actor is a Go struct plus a factory function. The factory is what you register with a host; Francis calls it to activate an actor on demand.
func(actorID string, service *actor.Service) actor.Actoractor.Actor is an alias for any, so your actor can be any type. What makes a struct an actor is the set of optional interfaces it implements.
type Cart struct {
client actor.Client[cartState]
log *slog.Logger
}
type cartState struct {
Items []string
}
func NewCart(actorID string, service *actor.Service) actor.Actor {
return &Cart{
client: actor.NewActorClient[cartState]("cart", actorID, service),
}
}A fresh instance is created on every activation. Hold per-activation data on the struct (such as the typed client ). Anything that must outlive the activation should be stored in the actor’s persistent state .
Actor methods#
An actor implements behavior by satisfying one or more of these interfaces. Implement only the ones you need.
Invoke: handle method calls#
Implement actor.ActorInvoke to handle invocations:
func (c *Cart) Invoke(ctx context.Context, method string, data actor.Envelope) (any, error) {
// "method" is the method name chosen by the caller
// "data" carries the request payload (may be nil)
switch method {
case "addItem":
var req struct{ Item string }
if data != nil {
err := data.Decode(&req)
if err != nil {
return nil, err
}
}
state, err := c.client.GetState(ctx)
if err != nil {
return nil, err
}
state.Items = append(state.Items, req.Item)
err = c.client.SetState(ctx, state, nil)
if err != nil {
return nil, err
}
return len(state.Items), nil
default:
return nil, fmt.Errorf("unknown method: %s", method)
}
}methodis an arbitrary string chosen by the caller - switch on it to dispatch.datais anactor.Envelope. Calldata.Decode(&dest)to decode the request payload into a Go value. It may benilwhen there is no payload.- The return value (
any) is serialized and returned to the caller, who decodes it from their ownEnvelope. Returnnilfor no response. - Returning an error fails the invocation, which returns the error to the caller.
Alarm: handle scheduled callbacks#
Implement actor.ActorAlarm to receive alarms
:
func (c *Cart) Alarm(ctx context.Context, name string, data actor.Envelope) error {
// "name" is the alarm name you chose when scheduling it
// "data" carries the optional data attached to the alarm (may be nil)
c.log.InfoContext(ctx, "alarm fired", "name", name)
return nil
}When an alarm fires, Francis activates the actor if needed and calls Alarm. See Alarms
for scheduling.
Deactivate: clean up before deactivation#
Implement actor.ActorDeactivate to run logic right before the actor is deactivated (because it went idle, was halted, or the host is shutting down):
func (c *Cart) Deactivate(ctx context.Context) error {
c.log.InfoContext(ctx, "cart deactivating")
return nil
}Keep Deactivate quick: it runs within a deactivation timeout (5 seconds by default, configurable per actor type). Use it to flush in-memory work, not for long-running tasks.
Important: an actor could disappear at any point because its host (or the physical node it’s on) crashes. In that case, the Deactivate method may not be invoked. Do not wait for the Deactivate to persist critical data.
Concurrency model#
An actor processes one invocation at a time. Calls to the same actor are serialized (turn-based concurrency), so you never need locks to protect the actor’s own fields or its state from concurrent access.
Different actors (different IDs, or different types) run concurrently across the cluster, so your app scales by having many actors rather than by making one actor handle parallel work.
The context passed to your methods is cancelled if the invocation times out or the host is shutting down.
Registering an actor#
Register each actor type with the host before calling Run:
err := h.RegisterActor("cart", NewCart, local.RegisterActorOptions{
IdleTimeout: 10 * time.Minute,
})RegisterActorOptions controls activation and retry behavior:
| Option | Default | Description |
|---|---|---|
IdleTimeout | 5m | How long an actor can stay idle before it’s deactivated. A negative value disables the idle timeout. |
DeactivationTimeout | 5s | Maximum time allowed for Deactivate to run. |
ConcurrencyLimit | 0 (unlimited) | Maximum number of actors of this type active on a single host. |
MaxAttempts | 3 | Maximum attempts when invoking the actor or running an alarm. |
InitialRetryDelay | 2s | Initial delay before retrying a failed invocation, with backoff. |
Invoking actors#
From outside an actor, e.g. an HTTP handler, use the actor.Service you got from host.Service():
resp, err := service.Invoke(ctx, "cart", "user-42", "addItem", map[string]any{"Item": "book"})
if err != nil {
// handle error
}
var count int
if resp != nil {
err = resp.Decode(&count)
}- The
dataargument is any value that serializes to JSON and it becomes theEnvelopeyour actor decodes. - The response is an
actor.Envelope(ornil), callDecodeto read it. - You can invoke from any host: Francis routes the call to whichever host owns the actor, activating it if needed.
Invoking only if active#
By default, invoking an actor activates it if it isn’t already. To invoke only when the actor is already active, pass actor.WithInvokeActiveOnly(). If the actor isn’t active, you get actor.ErrActorNotActive:
resp, err := host.Invoke(ctx, "cart", "user-42", "peek", nil, actor.WithInvokeActiveOnly())Calling another actor from an actor#
Because the factory hands you the *actor.Service, an actor can invoke other actors by calling service.Invoke(...). Avoid re-entrancy (actor A calling actor B, which calls back into A in the same call chain), since each actor handles one invocation at a time.
Streaming invocations#
For large request or response bodies, use InvokeStream, which streams bytes instead of buffering a JSON payload:
respContentType, resp, err := service.InvokeStream(
ctx, "report", "2026-q1", "render",
"application/json", requestBody,
)
if err != nil {
return err
}
defer resp.Close()
// read from resp...The host enforces a maximum request body size (configurable with WithMaxRequestBodySize).
Halting an actor#
Halting deactivates an actor immediately rather than waiting for the idle timeout.
From inside an actor, call Halt() on its client to deactivate after the current invocation returns:
func (c *Cart) Invoke(ctx context.Context, method string, data actor.Envelope) (any, error) {
if method == "checkout" {
// ... finalize ...
c.client.Halt() // deactivate once this invocation completes
return nil, nil
}
// ...
}
client.Halt()is deferred: it schedules the halt so it runs after the current invocation, avoiding a deadlock.
From outside, the service offers:
service.Halt(actorType, actorID): halt a specific actor active on this hostservice.HaltAll(): halt all actors active on this hostservice.HaltDeferred(actorType, actorID): non-blocking variant ofHalt
A halted actor is simply hybernated. Its state stays in the database, and the next invocation re-activates it.
Common errors#
Methods that invoke actors or manage state and alarms may return these sentinel errors (in package actor), which you can match with errors.Is:
| Error | Meaning |
|---|---|
ErrStateNotFound | No state exists for the actor (returned by GetState/DeleteState at the service level). |
ErrAlarmNotFound | The named alarm doesn’t exist. |
ErrActorNotActive | WithInvokeActiveOnly() was used and the actor isn’t active. |
ErrActorNotHosted | Halt targeted an actor that isn’t active on the current host. |
ErrActorHalted | The actor is halted on the host where it was active, retry after a delay. |
ErrActorTypeUnsupported | No host in the cluster serves this actor type. |
ErrNoHost | No host is currently available to place the actor. |