Actor state
Every actor has its own durable state: a Go value that Francis stores in the database (internally, serialized to JSON), keyed by the actor’s type and ID. State is independent of whether the actor is currently active: it survives deactivation, process restarts, and an actor moving between hosts.
Working with state through the client#
The most convenient way to manage an actor’s own state is the typed actor.Client[T], where T is the type of your state. You create one in the factory and keep it on the actor struct:
type cartState struct {
Items []string
}
type Cart struct {
client actor.Client[cartState]
}
func NewCart(actorID string, service *actor.Service) actor.Actor {
return &Cart{
client: actor.NewActorClient[cartState]("cart", actorID, service),
}
}Reading state#
state, err := c.client.GetState(ctx)GetState returns the actor’s state as a typed T. If the actor has no stored state yet, it returns the zero value of T (not an error), so you can treat “first time” and “existing” uniformly.
The client caches the state in memory for the lifetime of the activation. Because an actor handles one invocation at a time, this cache is always consistent: repeated GetState calls within an activation don’t re-read the database.
Writing state#
state.Items = append(state.Items, "book")
err := c.client.SetState(ctx, state, nil)SetState persists the value and updates the in-memory cache. State is only durable once SetState returns successfully: mutating the struct alone does not persist anything.
Deleting state#
err := c.client.DeleteState(ctx)DeleteState removes the stored state. After deletion, GetState again returns the zero value.
State TTL#
You can give state a time-to-live (TTL) so it expires automatically. Pass SetStateOpts to SetState:
err := c.client.SetState(ctx, state, &actor.SetStateOpts{
TTL: 24 * time.Hour,
})After the TTL elapses, the state is treated as absent and is eventually purged by the provider’s cleanup. This is useful for ephemeral actors whose state should not linger indefinitely (for example: user sessions, shopping carts…). Pass nil (or a zero TTL) for state that never expires (state has no TTL by default).
Listing the actors that have state#
ListStates returns the actors of a given type that have state stored in the database. This is how you enumerate the actors your application knows about: this listing covers every actor that has persisted state, whether or not it is active right now. Listing actor states does not re-activate actors that are idle.
list, err := c.client.ListStates(ctx, nil)
for _, s := range list.States {
fmt.Println(s.ActorID)
}Through the typed client, the listing is always scoped to the current actor’s own type. It is a read, so it’s also allowed inside a Peek invocation.
By default only the actor IDs are returned, which keeps the query cheap when you don’t need the payloads. Set IncludeData to also get each actor’s state, decoded into T:
list, err := c.client.ListStates(ctx, &actor.ListStatesOpts{IncludeData: true})
for _, s := range list.States {
fmt.Println(s.ActorID, len(s.Data.Items))
}Actors whose stored state is empty come back with the zero value of T.
Pagination#
Results are ordered by actor ID and returned one page at a time. Limit sets the page size (capped by the provider, and defaulting to 100 when unset), HasMore reports whether more actors follow, and After resumes the listing from a given actor ID.
AfterID is a convenience method giving you the cursor for the next page. It returns an empty string once there are no more pages, so a full walk of the collection is a loop that ends when the cursor runs out:
opts := actor.ListStatesOpts{Limit: 50}
for {
list, err := c.client.ListStates(ctx, &opts)
if err != nil {
return err
}
for _, s := range list.States {
fmt.Println(s.ActorID)
}
opts.After = list.AfterID()
if opts.After == "" {
break
}
}Because the cursor is an actor ID rather than an offset, actors added or removed while you are paging don’t shift the pages you have not read yet.
State that has expired through its TTL is never listed, even before the provider’s cleanup has removed it.
Accessing state through the service#
The typed client is built on top of actor.Service, which exposes the same operations for any actor:
// dest is decoded from the stored JSON
var state cartState
err := service.GetState(ctx, "cart", "user-42", &state)
err = service.SetState(ctx, "cart", "user-42", state, nil)
err = service.DeleteState(ctx, "cart", "user-42")
list, err := service.ListStates(ctx, "cart", &actor.ListStatesOpts{IncludeData: true})At the service level, GetState and DeleteState return actor.ErrStateNotFound when no state exists. The typed client smooths this over by returning a zero value instead. Use the service form when you need to manage another actor’s state, or when you want to distinguish “no state” from “zero state”.
ListStates takes the actor type to list, and takes the same options as the client’s. Since the service isn’t bound to a state type, each listed actor carries its state as an actor.Envelope you decode yourself (and which is nil when the data wasn’t requested):
for _, s := range list.States {
var state cartState
err = s.Data.Decode(&state)
}How state is stored#
State is serialized to JSON, so your state type must be JSON-serializable. A few practical notes:
- Use exported fields - unexported fields are not serialized.
- Keep state reasonably small — it’s read and written as a single value per actor. If you need to store large blobs (for example, full images), it’s best to save them somewhere else (like an object storage service) and keep only a reference in the actor’s state.
- The storage backend is your configured provider (SQLite or PostgreSQL).
Because state is durable and single-activation guarantees one writer at a time, you generally don’t need optimistic concurrency or your own locking for an actor’s own state.