Tutorial 06 / processes in regular Go
Keep the state Go-shaped.
Goer’s processes (small workers that each keep their own data) are usually written in imgo, its cut-down version of Go. Not everything fits there. Maybe your data is already a Go struct full of maps and interfaces. Maybe it has to be saved to a database you already have. You shouldn’t have to rewrite all that to get an inbox of messages and something that restarts you after a crash. A native actor is a process written in regular Go: same inbox, same restarts, your own data. In this tutorial you’ll register one, start it, send it work, and give it timeouts and slow jobs without blocking anything.
1. Pick native on purpose
A native actor owns one ordinary Go value. Its function handles one message and returns the next value. Even if ten goroutines call Pid.Tell at once, the mailbox lines the messages up and they’re handled one by one.
Regular Go memory, interfaces, any Go package you like. You can’t step through it instruction by instruction.
Its own block of memory, nothing can reach in, you can step through it, change its code live, and ship it to another machine.
2. Register it
Register the template from your regular Go code. The name is what imgo code will use to start it. The first function builds the starting value. It runs once at the start and again after every supervised restart.
type RunState struct {
Status string
Steps map[string]string
}
type StartRun struct{}
sys.RegisterNative("pipeline-coordinator",
func() any {
return RunState{Status: "pending", Steps: map[string]string{}}
},
func(raw any, message any) (any, error) {
state := raw.(RunState)
switch message.(type) {
case StartRun:
state.Status = "running"
}
return state, nil
},
)RegisterNative only saves the recipe. Nothing’s running yet. The actor comes to life when an imgo process starts it, or when your Go code calls sys.SpawnNative.
3. Start it from imgo
In a //go:build goer file, Native("name") is something you can spawn. The go form starts it and forgets the address.
//go:build goer
package app
import . "goer"
type RootState struct{}
type Start struct{}
type Msg interface{}
func Root(state RootState, message Msg) RootState {
switch message.(type) {
case Start:
go Native("pipeline-coordinator")
}
return state
}That’s shorthand for Spawn(Native("pipeline-coordinator")). If you want to keep the address, write pid := Spawn(Native("pipeline-coordinator")). Native actors only run on the machine that registered them, so you can’t pass one to Remote.
4. Send it messages
From Go, you can send a native actor any Go value. When an imgo process sends to it, the message arrives as a *msg.Spec, and you read its type and fields to work out what was meant.
func(raw any, message any) (any, error) {
state := raw.(RunState)
switch m := message.(type) {
case StepSucceeded:
state.Steps[m.ID] = "succeeded"
case *msg.Spec:
if m.Type == "CancelRun" {
state.Status = "cancelled"
}
}
return state, nil
}For work that happens elsewhere, let a port or task do the job and have it call pid.Tell(StepSucceeded{...}) or pid.Tell(StepFailed{...}) when it finishes. The actor stays the only thing that changes its own data.
Raw bytes are fine too. An imgo []byte field shows up in the *msg.Spec as a string field with the exact same bytes, and ports.FieldBytes turns it back into a []byte.
5. Watch it and recover it
Native actors show up in the dashboard right next to imgo ones. Their Kind is native. You can see how many messages they’ve handled and what’s waiting, and a crash report tells you the message type and the error.
Goer never shows your actor’s data to the dashboard on its own. If you want to show a few safe facts, like a status or a retry count, register a NativeInspector that returns just those.
pid := sys.SpawnNative("standalone", init, behavior, goer.RestartOnCrash)
_ = sys.SetTrace(pid, true)
info, _ := sys.MailboxSnapshot(pid)
snapshot := sys.Snapshot(100)After a crash the supervisor calls your starting function again. So if the data matters, that function has to load it from wherever you saved it. Goer makes sure changes happen one at a time. It doesn’t save anything to disk for you.
6. Timeouts
When a step needs a deadline, ask Goer for a timer. SendAfter delivers a message to the actor later, through the same mailbox as everything else. Keep the handle it returns so you can cancel the timer if the step finishes in time. (Inside imgo, the same thing is After(ms, Timeout{}).)
timer := sys.SendAfter(runPID, 30*time.Second, Timeout{Step: "build"})
func onStepSucceeded(...) {
timer.Cancel()
}A timeout is just another message, so it can arrive late. Here’s a made-up example of why that matters: a build step times out, you retry it, and then the old timeout message turns up and kills the retry. Put the run, the step, and the attempt number in your timeout message so you can tell an old one from a current one.
7. Slow work goes in a task
For running a command, doing a deploy, or anything else that takes a while, start a task. It runs off to the side with a context you can cancel, and it sends exactly one TaskResult back to the actor that owns it.
task, err := sys.StartTask(runPID, func(ctx context.Context) (any, error) {
return executor.Run(ctx, command)
})
if err != nil { return err }
// Handle TaskResult{ID: task.ID(), Value: ..., Err: ...}Hang on to the TaskID. If you start a retry before the old worker reports back, you can spot the stale result and ignore it. The same trick works for bigger jobs: give each request an ID, save it before you send it, and only accept an answer for a request you’re still waiting on.
To survive a restart, save the actor’s data together with any requests it hasn’t sent yet, before sending them. When it starts up again, load that and resend. The other side has to cope with seeing the same request ID twice.
8. See who owns what
System.Snapshot lists every running task and every pending timer, and says which actor owns each. It doesn’t show what’s inside them. For tasks you get the start time and whether someone asked to cancel. For timers you get the message type and when it’s due.
snapshot := sys.Snapshot(100)
for _, task := range snapshot.Tasks {
fmt.Println(task.Owner, task.ID, task.CancelRequested)
}
for _, timer := range snapshot.Timers {
fmt.Println(timer.Owner, timer.MessageType, timer.DueAt)
}The dashboard’s overview shows the same counts. One thing to know about cancelling: TaskHandle.Cancel cancels the context, and that’s all. Your task has to notice and return.
The pipeline example in the repo has a CoordinatorInspector that shows run status, active steps, retries, and the last failure. It’s a good one to copy. Keep inspectors small, since they run on the actor’s own time.